<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>Reclear Blog</title>
    <link>https://reclear.io/blog</link>
    <description>Technical content marketing agency that helps developer-first companies turn complex products into clear documentation, developer tutorials, and content strategies.</description>
    <language>en-us</language>
    <lastBuildDate>Mon, 05 Oct 2026 23:36:18 GMT</lastBuildDate>
    <atom:link href="https://reclear.io/rss.xml" rel="self" type="application/rss+xml" />
    <image>
      <url>https://reclear.io/logo_transparent.png</url>
      <title>Reclear Blog</title>
      <link>https://reclear.io/blog</link>
    </image>
    <item>
      <title>Generating Native PDFs from the Browser&apos;s Resolved Layout</title>
      <link>https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout</link>
      <guid isPermaLink="true">https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout</guid>
      <description>Generate native PDFs from the browser&apos;s resolved layout with selectable text, fillable fields, and vector SVGs, without running Chromium on a server.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/cover/cover.webp" alt="Generating Native PDFs from the Browser&apos;s Resolved Layout" /></p><aside><ul>
<li>Generating native PDFs can reuse the layout of a document already rendered in the user&#x27;s browser. JavaScript reads its geometry and writes PDF objects without starting Chromium on a server.</li>
<li>Browser coordinates need an origin adjustment, a pixels-to-points conversion, and a vertical flip. Text also needs its baseline, rather than the top of its bounding box.</li>
<li>Text can remain selectable, inputs can become fillable AcroForm fields, and supported SVG shapes can remain vectors. These require separate PDF emitters.</li>
<li>This approach fits application-owned invoices, receipts, and reports. It still requires accessible font and image bytes, template testing, and fallbacks for unsupported content.</li>
</ul></aside>
<p>Generating native PDFs for an invoice usually starts with Chromium: it reads the HTML and CSS, lays everything out, and produces a printable page. I normally use it too. But starting a browser just to render a receipt adds a browser binary, a process to run, and time and memory costs. The invoice I wanted to export was already in the user&#x27;s browser. Its position, style, and size had already been resolved.</p>
<p>This article walks through reading that layout and rebuilding it as native PDF text, paths, and fillable fields, including the mistakes that appeared in the first implementation.</p>
<aside><p>The runnable example uses <strong>Garri 0.1.0-alpha.3</strong>, whose standalone build includes <strong>pdf-lib 1.17.1</strong> and <strong>@pdf-lib/fontkit 1.1.1</strong>. The examples were checked in <strong>Chrome for Testing 152.0.7977.54 on macOS arm64</strong>. You&#x27;ll need a page served over HTTP or HTTPS and basic DOM, CSS, and JavaScript knowledge. The <a href="https://github.com/digitaldrreamer/garri">complete implementation is on GitHub</a>.</p></aside>
<h2>Why generating native PDFs starts with the DOM</h2>
<p>The first question was whether we needed another layout engine at all. The browser had already drawn the invoice. Why not read those values and rebuild the document with <a href="https://pdf-lib.js.org/">pdf-lib</a>?</p>
<p>This is different from taking a screenshot and putting it in a PDF. For example, <a href="https://github.com/eKoopmans/html2pdf.js#known-issues">html2pdf.js documents its image-based rendering pipeline</a>, which makes the resulting text unselectable and unsearchable. Native PDF generation writes text and shapes as PDF objects instead.</p>
<p>The invoice has text, borders, a total, and two fields for the recipient&#x27;s billing contact. Those fields should remain fillable after download. Here is the starting HTML rendered in the browser:</p>
<p><img src="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/diagram/browser-invoice-basic.webp" alt="The starting invoice is ordinary HTML with a heading, two recipient fields, an item, and a total."/></p>
<p>The document&#x27;s structure is small. This excerpt from the <a href="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/example/invoice.html">runnable invoice example</a> contains the parts we need to preserve:</p>
<pre><code class="language-html">&lt;article class=&quot;invoice&quot;&gt;
  &lt;p&gt;Invoice · INV-2048&lt;/p&gt;
  &lt;h1&gt;Billing details&lt;/h1&gt;
  &lt;p&gt;Confirm the recipient before the invoice is returned.&lt;/p&gt;

  &lt;form aria-label=&quot;Recipient contact&quot;&gt;
    &lt;label&gt;
      Recipient name
      &lt;input name=&quot;recipientName&quot; type=&quot;text&quot; required /&gt;
    &lt;/label&gt;
    &lt;label&gt;
      Email address
      &lt;input name=&quot;recipientEmail&quot; type=&quot;email&quot; required /&gt;
    &lt;/label&gt;
  &lt;/form&gt;

  &lt;p&gt;Native PDF implementation · $1,480.00&lt;/p&gt;
  &lt;p&gt;Total due · $1,480.00&lt;/p&gt;
&lt;/article&gt;
</code></pre>
<p>Once the browser has rendered that structure, its APIs expose different parts of the result:</p>
<table><thead><tr><th>Browser source</th><th>What it provides</th></tr></thead><tbody><tr><td><code>getBoundingClientRect()</code></td><td>An element&#x27;s position and size relative to the viewport</td></tr><tr><td><code>getComputedStyle()</code></td><td>Resolved colors, borders, fonts, and clipping properties</td></tr><tr><td><code>Range.getClientRects()</code></td><td>Rectangles for rendered text fragments</td></tr><tr><td>Canvas <code>measureText()</code></td><td>Font metrics used to recover text baselines</td></tr><tr><td>Form controls</td><td>Field type, name, current value, and required state</td></tr><tr><td>SVG <code>getScreenCTM()</code></td><td>A matrix mapping SVG coordinates into viewport coordinates</td></tr></tbody></table>
<p>The original invoice is 600 × 380 CSS pixels. The heading uses a 360 × 36 pixel box, and the first input uses a 255 × 40 pixel box. These are measurements of the rendered document, rather than dimensions guessed by the PDF generator:</p>
<p><img src="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/diagram/measurements-invoice-basic.webp" alt="Measured boxes outline the original 600 by 380 pixel invoice, its heading, the first input, and the total."/></p>
<p>You can inspect the outer box in the example&#x27;s browser console. Waiting for fonts avoids measuring the layout before its web font is ready:</p>
<pre><code class="language-javascript">await document.fonts.ready;

const invoice = document.querySelector(&quot;.invoice&quot;);
const rect = invoice.getBoundingClientRect();
const style = getComputedStyle(invoice);

console.table({
  x: rect.x,
  y: rect.y,
  width: rect.width,
  height: rect.height,
  background: style.backgroundColor,
  borderWidth: style.borderTopWidth,
  borderColor: style.borderTopColor,
  borderRadius: style.borderTopLeftRadius,
});
</code></pre>
<p>The width and height should read <code>600</code> and <code>380</code>. The viewport position depends on your window and scroll position. As <a href="https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect">MDN explains</a>, these coordinates include the element&#x27;s padding and border and change as you scroll.</p>
<p>Reading the values gives us a starting point. It does not tell pdf-lib how to draw every HTML or CSS feature. That translation is still our job.</p>
<h2>How to fix PDF coordinates</h2>
<p>I first copied the browser&#x27;s <code>x</code> and <code>y</code> values into the PDF. The parts of the invoice appeared in the opposite vertical order. The comparison makes the mistake visible:</p>
<p><img src="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/diagram/comparison-coordinate-fix.webp" alt="Copying browser y coordinates directly reverses the invoice&#x27;s vertical arrangement; converting them restores the layout."/></p>
<p>Browser coordinates start at the top left, while a default PDF page uses a bottom-left origin. They also use different units. <a href="https://www.w3.org/TR/css-values-3/#absolute-lengths">CSS defines 96 pixels and 72 points per inch</a>, so one CSS pixel corresponds to <code>0.75</code> PDF points at this scale. This is independent of the screen&#x27;s device pixel ratio.</p>
<p>Apply the conversion in this order:</p>
<ol>
<li>Subtract the invoice&#x27;s viewport origin from each child&#x27;s coordinates.</li>
<li>Add the PDF page margins and multiply CSS pixels by <code>0.75</code>.</li>
<li>Flip the vertical axis. For a rectangle, subtract its height as well, because PDF positions its lower edge.</li>
</ol>
<p>This console example computes where the first input rectangle belongs on an A4 page with an 18 mm margin:</p>
<pre><code class="language-javascript">const rootBox = document.querySelector(&quot;.invoice&quot;).getBoundingClientRect();
const inputBox = document.querySelector(&quot;input&quot;).getBoundingClientRect();
const pointsPerPixel = 72 / 96;
const pageHeightPt = (297 / 25.4) * 72;
const marginPt = (18 / 25.4) * 72;

const xPt = marginPt + (inputBox.left - rootBox.left) * pointsPerPixel;
const yPt = pageHeightPt - marginPt
  - (inputBox.top - rootBox.top + inputBox.height) * pointsPerPixel;

console.table({
  xPt,
  yPt,
  widthPt: inputBox.width * pointsPerPixel,
  heightPt: inputBox.height * pointsPerPixel,
});
</code></pre>
<p>Scroll the page and run it again. The input&#x27;s viewport coordinates change, but its position relative to the invoice should stay the same. That is the coordinate space the export needs.</p>
<h2>Place text on its baseline</h2>
<p>Fixing rectangles did not fix the text. I used the top of each text box as the position for its PDF text, and the heading moved into the invoice number above it:</p>
<p><img src="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/diagram/comparison-baseline-fix.webp" alt="Placing PDF text at the top of its box shifts the heading upward; using its baseline restores the spacing."/></p>
<p>PDF text is positioned on its baseline: the invisible line the letters sit on. An element&#x27;s box includes padding and line-height space, so its top is not the text baseline.</p>
<p>For an ordinary horizontal text fragment in the tested Chromium environment, I recovered the baseline from a text-node <code>Range</code> and the matching canvas font metrics. <a href="https://developer.mozilla.org/en-US/docs/Web/API/TextMetrics/fontBoundingBoxAscent"><code>fontBoundingBoxAscent</code></a> measures the distance from the baseline to the font bounding box&#x27;s top. It differs from <code>actualBoundingBoxAscent</code>, which measures the ink of a particular string.</p>
<p>The invoice heading is a single text node, so this console snippet can measure it directly:</p>
<pre><code class="language-javascript">await document.fonts.ready;

const heading = document.querySelector(&quot;.invoice h1&quot;);
const headingStyle = getComputedStyle(heading);
const range = document.createRange();
range.selectNodeContents(heading);

const context = document.createElement(&quot;canvas&quot;).getContext(&quot;2d&quot;);
context.font = `${headingStyle.fontStyle} ${headingStyle.fontWeight} ${headingStyle.fontSize} ${headingStyle.fontFamily}`;
context.textBaseline = &quot;alphabetic&quot;;

const textBox = range.getBoundingClientRect();
const ascent = context.measureText(heading.textContent).fontBoundingBoxAscent;
const baselinePx = textBox.top + ascent;

console.table({ topPx: textBox.top, ascentPx: ascent, baselinePx });
</code></pre>
<p>For text, convert this baseline into the PDF&#x27;s coordinate space. Do not subtract the text box&#x27;s height again. If a fragment&#x27;s top is at <code>100px</code> and its ascent is <code>31px</code>, its baseline is at <code>131px</code> in the browser&#x27;s top-down coordinates.</p>
<p>Wrapped text needs more work: group character ranges into individual line fragments, measure each fragment, and preserve its font and spacing. The browser has already chosen the line breaks; the exporter observes them.</p>
<p>I checked <a href="https://github.com/digitaldrreamer/garri/blob/master/experiments/gate1-baselines.js">the text geometry fixture</a> against Chromium&#x27;s own PDF output. In the current check, all 12 matched text fragments had a reported mean and maximum baseline difference of <code>0.0000px</code>. That is a focused fixture result, not a guarantee for every font, browser, or writing system. The earlier experiment described in the original article measured 18 fragments, rather than 18 separate test runs.</p>
<p>The result also depends on writing PDF text with an appropriate font. Measuring the browser&#x27;s positions does not replace font embedding or complex-script shaping.</p>
<h2>Keep input fields fillable</h2>
<p>With text in place, the invoice looked usable, but drawing the inputs as backgrounds and borders only preserved their appearance. The recipient still could not type into them:</p>
<p><img src="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/diagram/comparison-form-fix.webp" alt="The visual reconstruction has input-shaped boxes; the AcroForm version contains two interactive PDF fields."/></p>
<p>PDF uses AcroForm for interactive fields. For each supported HTML input, the converter creates a PDF field over the corresponding rectangle, derives a unique field name from the HTML name, and copies its current value and required flag. pdf-lib provides a <a href="https://pdf-lib.js.org/docs/api/classes/pdfform#createtextfield"><code>createTextField()</code> API</a> for this.</p>
<p>Field behavior needs its own translation. An HTML <code>type=&quot;email&quot;</code> input becomes a PDF text field; its browser email validation does not automatically transfer. A PDF required flag is metadata for the viewer, not a substitute for validating data when the completed document comes back.</p>
<p>PDF widgets also support less styling than HTML controls. That is why the native fields have different corners in the comparison.</p>
<p>You can also combine a styled input UI with a fillable field: draw the input&#x27;s background, border, and rounded corners as page content, then place an AcroForm field over the same rectangle. Configure the widget&#x27;s <a href="https://pdf-lib.js.org/docs/api/classes/pdftextfield#addtopage">appearance</a> with a transparent background and no border, and let the field render the value so the text is not drawn twice. This preserves the drawn styling while keeping the field editable.</p>
<p>Test both their appearance and their behavior in the PDF readers your users actually use. This is part of the <a href="https://reclear.io/blog/category/developer-experience">developer experience</a> of an export, just as much as the download button.</p>
<h2>Preserve SVG transforms</h2>
<p>The next addition was a small SVG invoice mark with two paths and a gradient. Reading the path data alone seemed sufficient, until the mark disappeared from the top-right corner:</p>
<p><img src="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/diagram/comparison-svg-transform-fix.webp" alt="SVG path data alone loses the invoice mark&#x27;s placement; applying the browser&#x27;s transform positions it correctly."/></p>
<p>A path describes geometry in its own SVG coordinate system. It does not include the SVG&#x27;s page position, its <code>viewBox</code> scale, or ancestor transforms.</p>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/API/SVGGraphicsElement/getScreenCTM"><code>getScreenCTM()</code></a> supplies the matrix that maps a shape into viewport coordinates. You then compose it with the same viewport-to-PDF conversion used for the invoice.</p>
<p>The runnable example includes the mark. Inspect its first path&#x27;s matrix in the browser console:</p>
<pre><code class="language-javascript">const path = document.querySelector(&quot;.invoice svg path&quot;);
const matrix = path.getScreenCTM();

console.table({
  a: matrix.a,
  b: matrix.b,
  c: matrix.c,
  d: matrix.d,
  e: matrix.e,
  f: matrix.f,
});
</code></pre>
<p>The <code>a</code>, <code>b</code>, <code>c</code>, and <code>d</code> entries describe the linear transform, and <code>e</code> and <code>f</code> describe translation. Moving the invoice changes the translation. Changing the mark&#x27;s CSS size relative to its <code>viewBox</code> changes the scale.</p>
<p>The emitter still needs to write the path, stroke, and gradient into the PDF. For this mark, the supported gradient becomes a PDF shading and the paths stay vector paths. An empty <code>pdfimages -list</code> output confirms that no bitmap was embedded; it does not establish that the mark&#x27;s position, colors, or geometry are correct. Those need a visual comparison too.</p>
<h2>Paginate without changing the document&#x27;s width</h2>
<p>The one-page invoice worked, so I moved on to page breaks. I turned the invoice itself into a column container, with one column for each PDF page. The invoice became wider, and the mark moved with its right edge:</p>
<p><img src="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/diagram/comparison-pagination-wrapper-fix.webp" alt="Using the invoice itself as a pagination container widens it; a separate wrapper preserves the invoice&#x27;s original geometry."/></p>
<p>The element used for pagination should not also be the document being rendered. Widening the invoice changes any child positioned from its right edge. The exporter would be measuring a different document from the one the user saw.</p>
<p>The fix was a plain wrapper around the invoice. The wrapper becomes the column container, while the invoice keeps its original width. Browser fragmentation then supplies page-sized column layouts that can be mapped to PDF pages. The runnable example uses a <code>.pdf-document</code> wrapper as the export root and keeps the 600 pixel <code>.invoice</code> inside it.</p>
<p>This lets us reuse the browser&#x27;s layout work, but pagination remains an implementation task. The exporter has to account for page geometry, page breaks, repeated table headers, and content that crosses a boundary. A single-page receipt does not prove that a long statement will paginate correctly.</p>
<h2>Generate and verify the invoice</h2>
<p>These fixes became <a href="https://github.com/digitaldrreamer/garri">Garri</a>, an open-source library that performs the capture and PDF emission inside the browser. You can <a href="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/example/invoice.html">open the complete runnable invoice example</a>, enter the recipient details, and download its PDF. The example uses a same-origin Figtree font so the generator can read and embed its bytes.</p>
<p>For a bundler-based application, install the versions used here:</p>
<pre><code class="language-bash">npm install garri@0.1.0-alpha.3 pdf-lib@1.17.1 @pdf-lib/fontkit@1.1.1
</code></pre>
<p>Once the invoice is rendered, pass its <code>.pdf-document</code> wrapper to the download helper from your export action:</p>
<pre><code class="language-javascript">import fontkit from &quot;@pdf-lib/fontkit&quot;;
import * as PDFLib from &quot;pdf-lib&quot;;
import { download } from &quot;garri&quot;;

await document.fonts.ready;

const documentRoot = document.querySelector(&quot;.pdf-document&quot;);
const result = await download(documentRoot, &quot;invoice.pdf&quot;, {
  pdfLib: PDFLib,
  fontkit,
  page: { widthMm: 210, heightMm: 297, marginMm: 18 },
});

console.log(`Created ${result.pages} page(s)`);
console.table(result.diagnostics);
</code></pre>
<p>The standalone script in the runnable example exposes the same helper as <code>Garri.download()</code> and includes the two PDF dependencies. The invoice should produce one page with two fillable text fields. Inspect <code>diagnostics</code> after generation, especially resource-access and font-substitution messages.</p>
<p>The original experiment&#x27;s final comparison shows the browser invoice beside its native PDF. The text remains text, and the two input fields remain interactive:</p>
<p><img src="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/diagram/comparison-invoice-basic.webp" alt="The original browser invoice and its native PDF preserve the document&#x27;s text and layout, with different PDF widget styling."/></p>
<p>You can also <a href="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/example/native-pdf-invoice.pdf">download the original generated invoice PDF</a> to inspect the output behind that comparison. It uses a custom page size; the runnable example above uses A4.</p>
<p>For command-line checks, install Poppler. On macOS, use Homebrew:</p>
<pre><code class="language-bash">brew install poppler
</code></pre>
<p>On Debian or Ubuntu, use the corresponding package:</p>
<pre><code class="language-bash">sudo apt install poppler-utils
</code></pre>
<p>Run these commands from the directory containing your downloaded <code>invoice.pdf</code>:</p>
<pre><code class="language-bash">pdfinfo invoice.pdf
pdfimages -list invoice.pdf
pdftotext invoice.pdf -
</code></pre>
<p>Check three separate properties: <code>pdfinfo</code> should report one page and <code>Form: AcroForm</code>; <code>pdfimages -list</code> should contain no image rows for this text-and-vector invoice; and <code>pdftotext</code> should recover its invoice number, labels, and total. Then open the PDF, select the text, fill both fields, save it, and reopen it to confirm that the values persist.</p>
<p>For longer documents, compare page count and page assignment with a reference PDF as well. Chromium&#x27;s <a href="https://pptr.dev/api/puppeteer.page.pdf"><code>Page.pdf()</code></a> uses print CSS by default, so keep media styles and page geometry consistent when comparing it with the captured browser layout. A good <a href="https://reclear.io/blog/category/tutorials">engineering tutorial</a> verifies the exported artifact, rather than treating a successful download as proof of correct rendering.</p>
<h2>When this approach breaks down</h2>
<p>The invoice proves that browser measurements can support native PDF reconstruction. It does not make the browser a complete PDF conversion API. Three boundaries matter in practice:</p>
<ol>
<li><strong>There must be a live browser document to read.</strong> This approach fits receipts, invoices, and reports already displayed inside an application. A scheduled server job or a service that accepts arbitrary webpages still needs another rendering strategy. If the user only needs a manual printout, the browser&#x27;s print dialog may already be enough.</li>
<li><strong>Layout measurements do not solve fonts, resources, or shaping.</strong> Fonts and images must expose bytes JavaScript is allowed to fetch. A browser can display a cross-origin resource that the exporter cannot read. System fonts may be substituted, and Garri&#x27;s current Arabic and Devanagari cluster shaping is not reliable. Check the <a href="https://github.com/digitaldrreamer/garri/blob/master/COMPATIBILITY.md">compatibility notes</a> before choosing it for those documents.</li>
<li><strong>The emitter supports a bounded set of paint and document features.</strong> Canvas content and outer box shadows use raster fallbacks. Some CSS and SVG effects are unsupported, and fillable widgets cannot reproduce all HTML styling. Selectable text also does not imply an accessible tagged PDF: Garri does not currently emit tagged structure. Review the <a href="https://github.com/digitaldrreamer/garri/blob/master/FEATURES.md">feature matrix</a> and test your actual templates.</li>
</ol>
<p>Within those limits, the architecture solves the problem I started with. The document is laid out in the browser the user already has open, and the generator writes the PDF there. A server can still provide data, check access, store the result, or send it, but it does not need to run the layout engine for each download.</p>
<p>This is an updated version of <a href="https://drreamer.digital/blog/generating-native-pdfs-from-the-browsers-resolved-layout">the original article published on drreamer.digital</a>.</p>]]></content:encoded>
      <pubDate>Sun, 04 Oct 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Wale Bashir)</author>
      <category>Tutorials</category>
      <category>DeveloperExperience</category>
      <enclosure url="https://reclear.io/blog/generating-native-pdfs-from-the-browsers-resolved-layout/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>How to Expose an Application with Kubernetes Ingress</title>
      <link>https://reclear.io/blog/how-to-expose-an-application-with-kubernetes-ingress</link>
      <guid isPermaLink="true">https://reclear.io/blog/how-to-expose-an-application-with-kubernetes-ingress</guid>
      <description>Learn how Kubernetes Ingress helps you expose applications to the internet, route traffic with Traefik, and secure your application with HTTPS and TLS.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/how-to-expose-an-application-with-kubernetes-ingress/cover/cover.webp" alt="How to Expose an Application with Kubernetes Ingress" /></p><aside><ul>
<li>Deploy a sample application to Kubernetes and expose it with a <code>ClusterIP</code> Service.</li>
<li>Install Traefik as the Ingress Controller and configure an Ingress resource to route traffic to the application.</li>
<li>Configure DNS and HTTPS with cert-manager and Let’s Encrypt.</li>
<li>Redirect HTTP traffic to HTTPS and verify the application is accessible through its domain.</li>
</ul></aside>
<p>A Kubernetes application can be running perfectly while still being inaccessible from the internet. If the application is exposed through a ClusterIP Service, external users cannot reach it directly. You could expose each application with a separate LoadBalancer, but managing multiple entry points can become difficult as the cluster grows. The challenge is routing external traffic to the correct application through a single entry point while also securing that traffic with HTTPS.</p>
<p>In this article, you&#x27;ll deploy an application on Kubernetes using a <code>ClusterIP</code> Service, install Traefik as the Ingress Controller with an external LoadBalancer, configure an Ingress resource and DNS, and secure the application with HTTPS using cert-manager and Let&#x27;s Encrypt.</p>
<aside><p>Before you begin, make sure you have:</p><ul>
<li>A <strong>Kubernetes cluster</strong> with at least one worker node.</li>
<li><strong><code>kubectl</code></strong> installed and configured to access the cluster.</li>
<li><strong>Helm</strong> installed for deploying Traefik.</li>
<li>A <strong>domain or subdomain</strong> that you can manage, such as <code>app.example.com</code>.</li>
<li>Access to the domain&#x27;s <strong>DNS records</strong> so you can point the domain to the Traefik LoadBalancer.</li>
<li>A <strong>publicly reachable Kubernetes cluster</strong> with an external IP or LoadBalancer that can receive HTTP and HTTPS traffic.</li>
<li>Basic familiarity with <strong>Kubernetes Deployments, Services, and Pods</strong>.</li>
</ul></aside>
<h2>How Kubernetes Ingress Works</h2>
<p>Kubernetes Ingress defines rules for routing external HTTP and HTTPS traffic to Services running inside a Kubernetes cluster. You can use these rules to route requests based on a domain name or URL path without exposing each application separately. <a href="https://kubernetes.io/docs/concepts/services-networking/ingress/">Kubernetes Ingress documentation</a></p>
<p>An <strong>Ingress Controller</strong> processes these rules and routes incoming traffic to the appropriate Service. In this article, you&#x27;ll use <a href="https://doc.traefik.io/traefik/reference/install-configuration/providers/kubernetes/kubernetes-ingress/"><strong>Traefik</strong></a> as the Ingress Controller.</p>
<p>The request flow is:</p>
<p><strong>Client → Traefik → Ingress → Service → Pod</strong></p>
<p><img src="https://reclear.io/blog/how-to-expose-an-application-with-kubernetes-ingress/diagram/kubernetes-ingress.webp" alt="Kubernetes Ingress request flow from the client through Traefik, Ingress, Service, and Pod"/></p>
<p>The <strong>Ingress</strong> defines how requests should be routed. Traefik receives the external request and uses the matching Ingress rule to forward it to the appropriate Service. The Service then routes the request to the application&#x27;s Pod.</p>
<p>For example, a request to <code>https://app.example.com</code> can be routed by Traefik to the <code>demo-app</code> Service, which forwards the request to the NGINX Pod. Replace <code>https://app.example.com</code> with the URL you configured for your application.</p>
<p>This gives you a single entry point for routing traffic to applications running inside the Kubernetes cluster.</p>
<h2>Deploy an Application to Kubernetes</h2>
<p>Before configuring Ingress, deploy a sample application inside the Kubernetes cluster. This example uses NGINX and exposes it through a <code>ClusterIP</code> Service.</p>
<p>Create a Deployment named <code>demo-app</code> using the NGINX image:</p>
<pre><code class="language-bash">kubectl create deployment demo-app --image=nginx
</code></pre>
<p>Check the Pod:</p>
<pre><code class="language-bash">kubectl get pods
</code></pre>
<p>You should see the Pod in the <code>Running</code> state:</p>
<pre><code class="language-text">NAME                        READY   STATUS    RESTARTS   AGE
demo-app-xxxxxxxxxx-xxxxx   1/1     Running   0          ...
</code></pre>
<p>Next, expose the Deployment through a <code>ClusterIP</code> Service:</p>
<pre><code class="language-bash">kubectl expose deployment demo-app \
  --port=80 \
  --target-port=80 \
  --name=demo-app
</code></pre>
<p>Verify the Service:</p>
<pre><code class="language-bash">kubectl get service demo-app
</code></pre>
<p>You should see output similar to:</p>
<pre><code class="language-text">NAME       TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
demo-app   ClusterIP   10.43.x.x      &lt;none&gt;        80/TCP    ...
</code></pre>
<p>The <code>ClusterIP</code> Service makes the NGINX application available inside the Kubernetes cluster, but it does not expose the application directly to the internet. In the next section, you&#x27;ll install Traefik and use it as the Ingress Controller to route external traffic to this Service.</p>
<h2>Install and Configure Traefik</h2>
<p>Traefik acts as the Ingress Controller and processes the Ingress resources you create in the Kubernetes cluster. Before installing it, add the Traefik Helm repository:</p>
<pre><code class="language-bash">helm repo add traefik https://traefik.github.io/charts
</code></pre>
<p>You should see:</p>
<pre><code class="language-text">&quot;traefik&quot; has been added to your repositories
</code></pre>
<p>Update the Helm repositories to retrieve the latest chart information:</p>
<pre><code class="language-bash">helm repo update
</code></pre>
<p>You should see output similar to:</p>
<pre><code class="language-text">Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the &quot;traefik&quot; chart repository
Update Complete. ⎈Happy Helming!⎈
</code></pre>
<p>Install Traefik in a dedicated <code>traefik</code> namespace:</p>
<pre><code class="language-bash">helm install traefik traefik/traefik \
  --namespace traefik \
  --create-namespace
</code></pre>
<p>Check the Traefik deployment:</p>
<pre><code class="language-bash">kubectl get pods -n traefik
</code></pre>
<p>You should see the Traefik Pod in the <code>Running</code> state:</p>
<pre><code class="language-text">NAME                       READY   STATUS    RESTARTS   AGE
traefik-xxxxxxxxxx-xxxxx   1/1     Running   0          ...
</code></pre>
<p>Check the Traefik Service:</p>
<pre><code class="language-bash">kubectl get service -n traefik
</code></pre>
<p>You should see a <code>LoadBalancer</code> Service similar to:</p>
<pre><code class="language-text">NAME      TYPE           CLUSTER-IP    EXTERNAL-IP   PORT(S)                      AGE
traefik   LoadBalancer   10.43.x.x     x.x.x.x       80:xxxxx/TCP,443:xxxxx/TCP   ...
</code></pre>
<p>The <code>LoadBalancer</code> Service provides the external entry point for Traefik. You will use its external IP address when configuring the DNS record for your application.</p>
<p>Next, verify that Traefik is available through its external IP:</p>
<pre><code class="language-bash">curl -I http://&lt;EXTERNAL-IP&gt;
</code></pre>
<p>Replace <code>&lt;EXTERNAL-IP&gt;</code> with the external IP address shown for the Traefik Service. You should receive an HTTP response from Traefik.</p>
<p>At this point, Traefik is running as the Ingress Controller and can process Ingress resources in the cluster. In the next section, you&#x27;ll create an Ingress resource and configure DNS to route your domain to Traefik.</p>
<h2>Configure Kubernetes Ingress and DNS</h2>
<p>Now that Traefik is running, create an Ingress resource to route requests from your domain to the <code>demo-app</code> Service. Kubernetes Ingress uses host and path rules to determine where incoming traffic should be sent.</p>
<p>Before creating the Ingress, get the external IP address assigned to the Traefik Service:</p>
<pre><code class="language-bash">kubectl get service -n traefik
</code></pre>
<p>Note the value under <code>EXTERNAL-IP</code>. You will use this address when creating the DNS record for your application.</p>
<p>Create a file named <code>demo-app-ingress.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo-app
spec:
  ingressClassName: traefik
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: demo-app
                port:
                  number: 80
</code></pre>
<p>Replace <code>app.example.com</code> with the domain or subdomain you want to use for your application.</p>
<p>The <code>ingressClassName</code> field associates the Ingress resource with the Traefik Ingress Controller. The <code>host</code> field defines the domain that Traefik should match, while the backend points the request to the <code>demo-app</code> Service on port 80.</p>
<p>Apply the Ingress configuration:</p>
<pre><code class="language-bash">kubectl apply -f demo-app-ingress.yaml
</code></pre>
<p>Check the Ingress:</p>
<pre><code class="language-bash">kubectl get ingress demo-app
</code></pre>
<p>You should see output similar to:</p>
<pre><code class="language-text">NAME        CLASS     HOSTS             ADDRESS      PORTS   AGE
demo-app    traefik   app.example.com   x.x.x.x      80      ...
</code></pre>
<p>The <code>ADDRESS</code> should eventually show the external IP address assigned to Traefik.</p>
<p>Next, configure a DNS record for the domain you specified in the Ingress. Create an <code>A</code> record that points your domain to the external IP address of the Traefik Service.</p>
<p>For example:</p>
<pre><code class="language-text">Type:   A
Name:   app
Value:  &lt;TRAEFIK-EXTERNAL-IP&gt;
</code></pre>
<p>Replace <code>&lt;TRAEFIK-EXTERNAL-IP&gt;</code> with the external IP address from the Traefik Service.</p>
<p>After the DNS record propagates, verify that the domain resolves to the Traefik external IP:</p>
<pre><code class="language-bash">dig +short app.example.com
</code></pre>
<p>The command should return the external IP address of your Traefik Service.</p>
<p>You can now access the application through your configured domain:</p>
<pre><code class="language-bash">curl http://app.example.com
</code></pre>
<p>You should receive the NGINX response:</p>
<pre><code class="language-text">&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Welcome to nginx!&lt;/title&gt;
</code></pre>
<p>At this point, requests to your domain are routed through Traefik to the <code>demo-app</code> Service and then to the NGINX Pod. In the next section, you&#x27;ll configure HTTPS using cert-manager and Let&#x27;s Encrypt.</p>
<h2>Secure the Application with HTTPS</h2>
<p>Now that your application is accessible through the configured domain, secure the connection with HTTPS. In this setup, <a href="https://cert-manager.io/docs/">cert-manager</a> obtains and renews the TLS certificate from Let&#x27;s Encrypt, while Traefik uses the generated Kubernetes Secret to terminate HTTPS traffic.</p>
<p>First, install cert-manager in the cluster:</p>
<pre><code class="language-bash">kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
</code></pre>
<p>Check that the cert-manager Pods are running:</p>
<pre><code class="language-bash">kubectl get pods -n cert-manager
</code></pre>
<p>You should see the cert-manager components in the <code>Running</code> state:</p>
<pre><code class="language-text">NAME                                       READY   STATUS    RESTARTS   AGE
cert-manager-xxxxxxxxxx-xxxxx              1/1     Running   0          ...
cert-manager-cainjector-xxxxxxxxxx-xxxxx   1/1     Running   0          ...
cert-manager-webhook-xxxxxxxxxx-xxxxx      1/1     Running   0          ...
</code></pre>
<p>Next, create a <code>ClusterIssuer</code> that uses Let&#x27;s Encrypt to issue certificates:</p>
<pre><code class="language-yaml">apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    email: your-email@example.com
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik
</code></pre>
<p>Replace <code>your-email@example.com</code> with your email address.</p>
<p>The <code>ClusterIssuer</code> defines Let&#x27;s Encrypt as the certificate authority. The HTTP-01 solver allows Let&#x27;s Encrypt to verify that you control the domain through an HTTP request handled by Traefik.</p>
<p>Save the configuration as <code>letsencrypt-issuer.yaml</code> and apply it:</p>
<pre><code class="language-bash">kubectl apply -f letsencrypt-issuer.yaml
</code></pre>
<p>Verify that the <code>ClusterIssuer</code> is ready:</p>
<pre><code class="language-bash">kubectl get clusterissuer
</code></pre>
<p>You should see:</p>
<pre><code class="language-text">NAME               READY   AGE
letsencrypt-prod   True    ...
</code></pre>
<p>Next, update the Ingress resource to request a TLS certificate from the <code>letsencrypt-prod</code> ClusterIssuer:</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo-app
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - app.example.com
      secretName: demo-app-tls
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: demo-app
                port:
                  number: 80
</code></pre>
<p>Replace <code>app.example.com</code> with the domain you configured for your application.</p>
<p>The <code>cert-manager.io/cluster-issuer</code> annotation tells cert-manager which <code>ClusterIssuer</code> to use. The <code>tls</code> section specifies the domain and the Kubernetes Secret where cert-manager will store the generated certificate. Traefik can then use this Secret to terminate HTTPS connections.</p>
<p>Apply the updated Ingress:</p>
<pre><code class="language-bash">kubectl apply -f demo-app-ingress.yaml
</code></pre>
<p>Check the Certificate resource:</p>
<pre><code class="language-bash">kubectl get certificate
</code></pre>
<p>You should see:</p>
<pre><code class="language-text">NAME          READY   SECRET         AGE
demo-app-tls  True    demo-app-tls   ...
</code></pre>
<p>You can also check the certificate request and related resources if the certificate is still being issued:</p>
<pre><code class="language-bash">kubectl describe certificate demo-app-tls
</code></pre>
<p>Once the certificate is ready, verify that the TLS Secret exists:</p>
<pre><code class="language-bash">kubectl get secret demo-app-tls
</code></pre>
<p>You should see:</p>
<pre><code class="language-text">NAME           TYPE                DATA   AGE
demo-app-tls   kubernetes.io/tls   2      ...
</code></pre>
<p>Finally, verify that the application is accessible through HTTPS:</p>
<pre><code class="language-bash">curl -I https://app.example.com
</code></pre>
<p>You should receive an HTTPS response from the application.</p>
<p>At this point, cert-manager has obtained a TLS certificate from Let&#x27;s Encrypt, stored it in a Kubernetes Secret, and Traefik is using the certificate to serve your application over HTTPS. In the next section, you&#x27;ll configure HTTP traffic to redirect automatically to HTTPS.</p>
<h2>Redirect HTTP Traffic to HTTPS</h2>
<p>Now that HTTPS is configured, redirect HTTP requests to HTTPS so visitors always access your application through an encrypted connection.</p>
<p>Create a Traefik middleware that redirects HTTP requests to HTTPS:</p>
<pre><code class="language-yaml">apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: redirect-to-https
  namespace: default
spec:
  redirectScheme:
    scheme: https
    permanent: true
</code></pre>
<p>Save the configuration as <code>redirect-to-https.yaml</code> and apply it:</p>
<pre><code class="language-bash">kubectl apply -f redirect-to-https.yaml
</code></pre>
<p>Verify that the middleware was created:</p>
<pre><code class="language-bash">kubectl get middleware redirect-to-https
</code></pre>
<p>You should see:</p>
<pre><code class="language-text">NAME                AGE
redirect-to-https   ...
</code></pre>
<p>Next, update the Ingress resource to use the middleware for HTTP traffic:</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo-app
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
    traefik.ingress.kubernetes.io/router.middlewares: default-redirect-to-https@kubernetescrd
spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - app.example.com
      secretName: demo-app-tls
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: demo-app
                port:
                  number: 80
</code></pre>
<p>Replace <code>app.example.com</code> with the domain you configured for your application.</p>
<p>Apply the updated Ingress:</p>
<pre><code class="language-bash">kubectl apply -f demo-app-ingress.yaml
</code></pre>
<p>Verify the Ingress:</p>
<pre><code class="language-bash">kubectl get ingress demo-app
</code></pre>
<p>Finally, send an HTTP request to your domain and check that it redirects to HTTPS:</p>
<pre><code class="language-bash">curl -I http://app.example.com
</code></pre>
<p>You should see a <code>301</code> redirect with a <code>Location</code> header pointing to the HTTPS URL:</p>
<pre><code class="language-text">HTTP/1.1 301 Moved Permanently
Location: https://app.example.com/
</code></pre>
<p>You can also verify that the HTTPS endpoint is accessible:</p>
<pre><code class="language-bash">curl -I https://app.example.com
</code></pre>
<p>The application now redirects HTTP requests to HTTPS, ensuring that traffic reaches the application through the TLS-secured endpoint.</p>
<h2>Troubleshoot and Clean Up</h2>
<p>If the application is not accessible through the configured domain, check the Ingress, Traefik Service, and application Service to identify where the request is failing.</p>
<p>First, check the Ingress resource:</p>
<pre><code class="language-bash">kubectl get ingress demo-app
</code></pre>
<p>If the <code>ADDRESS</code> field is empty, check the Traefik Service:</p>
<pre><code class="language-bash">kubectl get service -n traefik
</code></pre>
<p>Make sure the Traefik Service has an external IP address. If it does not, check the Traefik Pod and its events:</p>
<pre><code class="language-bash">kubectl get pods -n traefik
</code></pre>
<pre><code class="language-bash">kubectl describe service traefik -n traefik
</code></pre>
<p>If the Ingress has an address but requests are still failing, verify that the application Service has endpoints:</p>
<pre><code class="language-bash">kubectl get endpoints demo-app
</code></pre>
<p>You should see the IP address of the NGINX Pod associated with the Service.</p>
<p>If the endpoints are empty, check the application Pod and Service configuration:</p>
<pre><code class="language-bash">kubectl get pods
</code></pre>
<pre><code class="language-bash">kubectl describe service demo-app
</code></pre>
<p>Also verify that the Ingress is using the correct Service name and port:</p>
<pre><code class="language-bash">kubectl describe ingress demo-app
</code></pre>
<p>If HTTPS is not working, check the certificate status:</p>
<pre><code class="language-bash">kubectl get certificate
</code></pre>
<p>If the certificate is not ready, inspect the certificate and its events:</p>
<pre><code class="language-bash">kubectl describe certificate demo-app-tls
</code></pre>
<p>You can also check the certificate requests:</p>
<pre><code class="language-bash">kubectl get certificaterequest
</code></pre>
<p>For DNS-related issues, verify that your domain resolves to the external IP address of the Traefik Service:</p>
<pre><code class="language-bash">dig +short app.example.com
</code></pre>
<p>The returned IP address should match the external IP of the Traefik Service.</p>
<p>Once you have finished testing the configuration, remove the resources created during the tutorial.</p>
<p>Delete the Ingress:</p>
<pre><code class="language-bash">kubectl delete ingress demo-app
</code></pre>
<p>Delete the Traefik middleware:</p>
<pre><code class="language-bash">kubectl delete middleware redirect-to-https
</code></pre>
<p>Delete the application Service and Deployment:</p>
<pre><code class="language-bash">kubectl delete service demo-app
</code></pre>
<pre><code class="language-bash">kubectl delete deployment demo-app
</code></pre>
<p>Delete the <code>ClusterIssuer</code>:</p>
<pre><code class="language-bash">kubectl delete clusterissuer letsencrypt-prod
</code></pre>
<p>Finally, uninstall Traefik and remove its namespace:</p>
<pre><code class="language-bash">helm uninstall traefik -n traefik
</code></pre>
<pre><code class="language-bash">kubectl delete namespace traefik
</code></pre>
<p>If you installed cert-manager specifically for this tutorial and do not need it for other workloads, remove it with:</p>
<pre><code class="language-bash">kubectl delete -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
</code></pre>
<p>This removes the resources created during the tutorial and returns the cluster to its previous state.</p>
<h2>When this approach breaks down</h2>
<p>Kubernetes Ingress works well for routing HTTP and HTTPS traffic, but it may not be suitable for every application or networking requirement.</p>
<ul>
<li>
<p><strong>You need more advanced traffic management.</strong> If you need more expressive routing, traffic policies, or advanced networking features, consider using the Gateway API.</p>
</li>
<li>
<p><strong>You use multiple Ingress Controllers.</strong> Running multiple controllers can make routing more complex. Configure <code>IngressClass</code> and <code>ingressClassName</code> carefully so each Ingress resource is handled by the intended controller.</p>
</li>
<li>
<p><strong>You need to expose non-HTTP traffic.</strong> Ingress is designed primarily for HTTP and HTTPS traffic. Applications that use other protocols may require a different exposure method.</p>
</li>
<li>
<p><strong>You need capabilities beyond the Ingress API.</strong> The Kubernetes Ingress API is stable but frozen. For new networking capabilities, Kubernetes recommends the Gateway API.</p>
</li>
</ul>
<p>Ingress remains useful for straightforward HTTP and HTTPS routing, but these requirements may call for a different networking approach or additional Kubernetes resources.</p>]]></content:encoded>
      <pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Abdul Talha)</author>
      <category>Tutorials</category>
      <category>Kubernetes</category>
      <category>CloudNative</category>
      <category>DevOps</category>
      <enclosure url="https://reclear.io/blog/how-to-expose-an-application-with-kubernetes-ingress/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Sign Container Images with Cosign in GitHub Actions</title>
      <link>https://reclear.io/blog/sign-container-images-with-cosign-github-actions</link>
      <guid isPermaLink="true">https://reclear.io/blog/sign-container-images-with-cosign-github-actions</guid>
      <description>Learn how to sign container images with Cosign in GitHub Actions using keyless OIDC identity, then verify the signature before a Kubernetes deployment can proceed.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/sign-container-images-with-cosign-github-actions/cover/cover.webp" alt="Sign Container Images with Cosign in GitHub Actions" /></p><aside><ul>
<li>A mutable container tag such as <code>latest</code> identifies no immutable build and does not prove who published it.</li>
<li>Cosign <code>2.4.1</code> can sign an image keylessly in GitHub Actions using the workflow&#x27;s OIDC identity instead of a stored private key.</li>
<li>Verify the image digest, certificate identity, and OIDC issuer before deployment, not only after the image reaches a registry.</li>
<li>Signing establishes provenance and integrity; it does not replace vulnerability scanning, least privilege, or runtime admission policy.</li>
</ul></aside>
<p>A container registry can tell you that an image exists, but a tag such as <code>payments:latest</code> does not tell you which build produced it or whether the tag moved after review. If an attacker can push to the repository or registry, the deployment may pull a different image while the manifest stays unchanged.</p>
<p>This article signs a container image with Cosign <code>2.4.1</code> in GitHub Actions using keyless OIDC identity, then verifies the signature against the exact GitHub repository and workflow issuer before deployment.</p>
<aside><p><strong>What you need:</strong> Docker <code>27.3.1</code> or later, Cosign <code>2.4.1</code>, a GitHub repository, and an OCI-compatible registry such as GitHub Container Registry. The signing job uses GitHub-hosted runners and does not store a Cosign private key.</p></aside>
<h2>Why unsigned container images reach production</h2>
<p>A digest protects integrity only after someone has decided which digest is trusted. A registry login protects who can push, but it does not give a deployment system enough information to distinguish an approved build from another image the same account can publish.</p>
<p>The <a href="https://github.com/opencontainers/image-spec/blob/main/manifest.md">OCI image specification</a> defines image manifests and digests. <a href="https://docs.sigstore.dev/cosign/signing/signing_with_containers/">Sigstore&#x27;s Cosign documentation</a> adds a verifiable signature and certificate identity to that artifact.</p>
<table><thead><tr><th>Control</th><th>Answers</th><th>Does not answer</th></tr></thead><tbody><tr><td>Image tag</td><td>What friendly name did the publisher use?</td><td>Whether the tag moved or who built it</td></tr><tr><td>Image digest</td><td>Which immutable bytes are referenced?</td><td>Whether the publisher is trusted</td></tr><tr><td>Registry authentication</td><td>Who can push or pull?</td><td>Whether this digest came from the approved workflow</td></tr><tr><td>Cosign signature</td><td>Which identity signed this digest?</td><td>Whether the image has no vulnerabilities</td></tr></tbody></table>
<p>The failure is treating a registry push as an attestation. The fix is to make signing part of the build and make verification a required deployment input.</p>
<h2>Build an image with an immutable reference</h2>
<p>Start with a small image so the tutorial has one artifact and one digest to track. The following container prints a response and does not require an application framework.</p>
<p>Create <code>app/server.js</code>:</p>
<pre><code class="language-javascript">const http = require(&quot;node:http&quot;)

const server = http.createServer((_request, response) =&gt; {
  response.writeHead(200, { &quot;content-type&quot;: &quot;text/plain&quot; })
  response.end(&quot;signed image is running\n&quot;)
})

server.listen(8080, &quot;0.0.0.0&quot;)
</code></pre>
<p>Create <code>Dockerfile</code>:</p>
<pre><code class="language-dockerfile">FROM node:22.14.0-alpine3.21

WORKDIR /app
COPY app/server.js ./server.js

USER node
EXPOSE 8080
CMD [&quot;node&quot;, &quot;server.js&quot;]
</code></pre>
<p>Build and run the image locally to verify the application before adding signing:</p>
<pre><code class="language-bash">docker build --tag ghcr.io/OWNER/secure-demo:local .
docker run --rm --publish 8080:8080 ghcr.io/OWNER/secure-demo:local
</code></pre>
<p>In another terminal, <code>curl http://localhost:8080</code> should return <code>signed image is running</code>. Replace <code>OWNER</code> with the GitHub account or organization that owns the repository before using the registry reference in a workflow.</p>
<h2>Sign the image with Cosign in GitHub Actions</h2>
<p>Keyless signing needs two GitHub Actions permissions: <code>id-token: write</code> so the runner can request an OIDC token, and <code>packages: write</code> so it can publish the image and signature to GitHub Container Registry. Keep those permissions at the job or workflow boundary where they are needed.</p>
<p>Create <code>.github/workflows/build-sign-push.yml</code>:</p>
<pre><code class="language-yaml">name: Build and sign image

on:
  push:
    branches: [main]
    tags: [&quot;v*.*.*&quot;]

permissions:
  contents: read
  id-token: write
  packages: write

jobs:
  build-sign-push:
    runs-on: ubuntu-24.04
    env:
      IMAGE: ghcr.io/${{ github.repository }}
    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ env.IMAGE }}:${{ github.sha }}

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3.7.0
        with:
          cosign-release: &quot;v2.4.1&quot;

      - name: Sign image by digest
        env:
          COSIGN_EXPERIMENTAL: &quot;1&quot;
        run: |
          digest=$(docker buildx imagetools inspect &quot;$IMAGE:${GITHUB_SHA}&quot; --format &#x27;{{json .Manifest.Digest}}&#x27; | tr -d &#x27;&quot;&#x27;)
          cosign sign --yes &quot;$IMAGE@$digest&quot;
          echo &quot;IMAGE_DIGEST=$IMAGE@$digest&quot; &gt;&gt; &quot;$GITHUB_ENV&quot;

      - name: Verify the signature
        env:
          COSIGN_EXPERIMENTAL: &quot;1&quot;
        run: |
          cosign verify \
            --certificate-identity &quot;https://github.com/${GITHUB_REPOSITORY}/.github/workflows/build-sign-push.yml@refs/heads/main&quot; \
            --certificate-oidc-issuer &quot;https://token.actions.githubusercontent.com&quot; \
            &quot;$IMAGE_DIGEST&quot;
</code></pre>
<p>The workflow signs the digest, not the mutable tag. The certificate identity binds the signature to this repository&#x27;s workflow on the <code>main</code> branch. Keep the workflow path and branch in the verification command aligned with your actual publishing workflow.</p>
<p>The first run should publish an image tagged with the commit SHA, create a signature in the registry&#x27;s OCI referrers, and finish with Cosign verification output. The job does not read a Cosign private key because the signing certificate is issued for the workflow&#x27;s short-lived identity.</p>
<h2>How to verify the signature before deployment</h2>
<p>A deployment should consume a digest and verify its provenance in the same pipeline. Do not verify a tag and deploy a tag separately because the tag can move between those operations.</p>
<p>Set the image digest from the successful build job or release metadata, then run this command in a job that has no registry write permission:</p>
<pre><code class="language-bash">export IMAGE_DIGEST=&quot;ghcr.io/OWNER/secure-demo@sha256:REPLACE_WITH_BUILD_DIGEST&quot;

cosign verify \
  --certificate-identity &quot;https://github.com/OWNER/REPOSITORY/.github/workflows/build-sign-push.yml@refs/heads/main&quot; \
  --certificate-oidc-issuer &quot;https://token.actions.githubusercontent.com&quot; \
  &quot;$IMAGE_DIGEST&quot;
</code></pre>
<p>A successful result includes the certificate subject and the signed digest. A changed digest, a different repository, a pull request workflow, or a different branch should make verification fail.</p>
<p>For Kubernetes, put the verified digest in the workload manifest rather than the tag:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: signed-demo
spec:
  selector:
    matchLabels:
      app: signed-demo
  template:
    metadata:
      labels:
        app: signed-demo
    spec:
      containers:
        - name: signed-demo
          image: ghcr.io/OWNER/secure-demo@sha256:REPLACE_WITH_BUILD_DIGEST
          ports:
            - containerPort: 8080
</code></pre>
<p>This manifest records which bytes to run, but it does not perform admission-time verification by itself. For cluster-wide enforcement, use a policy engine such as <a href="https://kyverno.io/docs/policy-types/cluster-policy/verify-images/">Kyverno&#x27;s image verification policy</a> or <a href="https://docs.sigstore.dev/policy-controller/overview/">Sigstore Policy Controller</a>.</p>
<h2>How to fix common verification failures</h2>
<p>Signature verification errors are usually identity or artifact-reference errors. Diagnose them in this order.</p>
<ol>
<li>Confirm that the digest is the digest that the signing job printed. Rebuilding the same source can produce a different digest because the build context or base image changed.</li>
<li>Confirm the certificate identity includes the correct owner, repository, workflow path, and branch. A signature from a feature branch should not satisfy a production policy intended for <code>main</code>.</li>
<li>Confirm the OIDC issuer is <code>https://token.actions.githubusercontent.com</code>. Do not remove issuer checking to make a verification command pass.</li>
<li>Confirm the registry preserves Cosign signatures and referrers. Some proxy or mirroring configurations copy the image manifest but omit related signature artifacts.</li>
<li>Keep vulnerability scanning separate. <a href="https://reclear.io/blog/how-to-fix-crashloopbackoff-in-kubernetes">CrashLoopBackOff troubleshooting</a> helps diagnose runtime failures, but it cannot establish image provenance before scheduling.</li>
</ol>
<p>If your workflow uses reusable workflows, verify the certificate identity emitted by the actual signing workflow and document that identity as part of the deployment policy.</p>
<h2>When this approach breaks down</h2>
<p>Keyless signing removes private-key storage, but it does not make the build pipeline trustworthy by itself.</p>
<ul>
<li>A compromised workflow with <code>id-token: write</code> can sign malicious code as the repository. Protect the workflow, pin third-party actions, require review for workflow changes, and restrict who can publish releases.</li>
<li>A valid signature does not prove that the image is vulnerability-free. Run a scanner such as Trivy and define a separate severity policy for release blocking.</li>
<li>Registry mirroring, garbage collection, or unsupported referrer handling can separate signatures from images. Test the complete path from build registry to deployment registry.</li>
<li>Certificate identity checks are only as specific as their patterns. A broad identity match can accept signatures from unintended branches or workflows.</li>
</ul>
<p>Treat the signed digest as one release artifact alongside the vulnerability report, software bill of materials, and deployment approval.</p>]]></content:encoded>
      <pubDate>Sat, 12 Sep 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Ilyas Rufai)</author>
      <category>Tutorials</category>
      <category>DevSecOps</category>
      <category>GitHubActions</category>
      <category>Security</category>
      <enclosure url="https://reclear.io/blog/sign-container-images-with-cosign-github-actions/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>How to Deploy a Stateful Application on Kubernetes</title>
      <link>https://reclear.io/blog/how-to-deploy-a-stateful-application-on-kubernetes</link>
      <guid isPermaLink="true">https://reclear.io/blog/how-to-deploy-a-stateful-application-on-kubernetes</guid>
      <description>Learn how to deploy a stateful application on Kubernetes with persistent storage to keep your application data available when Pods restart or are recreated.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/how-to-deploy-a-stateful-application-on-kubernetes/cover/cover.webp" alt="How to Deploy a Stateful Application on Kubernetes" /></p><aside><ul>
<li>Use a Kubernetes StatefulSet to manage a stateful application that requires stable Pod identity and persistent storage.</li>
<li>Configure a PersistentVolumeClaim (PVC) with volumeClaimTemplates so each StatefulSet Pod gets dedicated persistent storage.</li>
<li>Deploy the application and verify that the StatefulSet, Pod, and PVC are running correctly.</li>
<li>Delete the Pod and verify that Kubernetes recreates it with the application&#x27;s data still available.</li>
</ul></aside>
<p>A stateful application can appear to work correctly until its Pod is recreated. If the application stores data on the Pod&#x27;s ephemeral filesystem, that data can be lost when Kubernetes replaces the Pod. This can leave you with a running application that has unexpectedly lost its data. The challenge is giving the application storage that persists independently of the Pod while Kubernetes continues to manage the workload.</p>
<p>In this article, you&#x27;ll deploy a stateful application on Kubernetes using a StatefulSet and persistent storage, configure a PersistentVolumeClaim (PVC) through volumeClaimTemplates, and verify that the application&#x27;s data remains available after its Pod is recreated.</p>
<aside><p>Before you begin, make sure you have:</p><ul>
<li>A running Kubernetes cluster with Kubernetes v1.34 or later.</li>
<li><code>kubectl</code> v1.34 or later, configured to access the cluster. <code>kubectl</code> must be within one minor version of the cluster&#x27;s control plane.</li>
<li>A default <code>StorageClass</code> that supports dynamic PersistentVolume provisioning.</li>
<li>Basic familiarity with Pods, Services, PersistentVolumes, and <code>kubectl</code>.</li>
<li>A cluster where you can safely create and delete workloads for testing.</li>
</ul></aside>
<h2>Why storing data in the Pod fails</h2>
<p>A stateful application can lose its data even when the Kubernetes workload is running normally. If the application writes data to a Pod&#x27;s ephemeral filesystem, that data is tied to the Pod rather than stored on persistent storage.</p>
<p>For example, this Deployment uses an <code>emptyDir</code> volume mounted at <code>/data</code>:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
name: stateful-app
spec:
replicas: 1
selector:
    matchLabels:
    app: stateful-app
template:
    metadata:
    labels:
        app: stateful-app
    spec:
    containers:
        - name: app
        image: nginx:1.27
        volumeMounts:
            - name: data
            mountPath: /data
    volumes:
        - name: data
        emptyDir: {}
</code></pre>
<p>The <code>emptyDir</code> volume exists for the lifetime of the Pod. If Kubernetes deletes the Pod and creates a replacement, the replacement gets a new <code>emptyDir</code> volume.</p>
<p>The application can return to a <code>Running</code> state, but the data written to <code>/data</code> is gone. This is the failure mode we need to avoid when deploying a stateful application.</p>
<p>Persistent storage separates the application&#x27;s data from the Pod lifecycle. Kubernetes provides PersistentVolumes and PersistentVolumeClaims for this purpose, while a StatefulSet can create a dedicated PVC for each Pod through <code>volumeClaimTemplates</code>.</p>
<h2>How StatefulSet preserves application data</h2>
<p>A StatefulSet gives each Pod a stable identity and can associate that Pod with dedicated persistent storage. Unlike a Deployment, StatefulSet Pods are not treated as interchangeable replicas. Kubernetes keeps track of each Pod&#x27;s identity and its associated storage. <a href="https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/">Kubernetes StatefulSets documentation</a></p>
<p><img src="https://reclear.io/blog/how-to-deploy-a-stateful-application-on-kubernetes/diagram/statefulset-architecture-v2.webp" alt="Architecture diagram showing a StatefulSet, Pods, and persistent storage"/></p>
<p>For example, a StatefulSet with three replicas creates Pods with predictable names such as <code>app-0</code>, <code>app-1</code>, and <code>app-2</code>. With <code>volumeClaimTemplates</code>, each Pod can also receive its own PersistentVolumeClaim instead of sharing a single volume.</p>
<p>If <code>app-0</code> is deleted, Kubernetes recreates <code>app-0</code> with the same identity. The replacement Pod can mount the existing PersistentVolumeClaim, allowing the application to access the data stored by the previous Pod.</p>
<p>This is the key difference for stateful workloads. The StatefulSet manages the Pod&#x27;s identity and lifecycle, while the PersistentVolume and PersistentVolumeClaim provide storage that can outlive the individual Pod. <a href="https://kubernetes.io/docs/concepts/storage/persistent-volumes/">Kubernetes Persistent Volumes documentation</a></p>
<h2>Create the application configuration</h2>
<p>Start with a headless Service so the StatefulSet Pods can receive stable network identities. The Service does not expose the application outside the cluster; it provides the DNS records StatefulSet uses to address individual Pods.</p>
<p>Create a file named <code>stateful-app.yaml</code> with the following configuration:</p>
<pre><code class="language-yaml">    apiVersion: v1
    kind: Service
    metadata:
      name: stateful-app
    spec:
      clusterIP: None
      selector:
        app: stateful-app
    ports:
      - port: 80
        targetPort: 80
</code></pre>
<p>The <code>clusterIP: None</code> setting makes this a headless Service. Kubernetes can then create DNS records for the individual StatefulSet Pods instead of assigning the Service a single cluster IP.</p>
<p>Next, add the StatefulSet configuration to the same file. This example uses <code>nginx:1.27</code> and creates a dedicated 1 GiB PersistentVolumeClaim for each Pod:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: stateful-app
spec:
  serviceName: stateful-app
  replicas: 1
  selector:
    matchLabels:
      app: stateful-app
  template:
    metadata:
      labels:
        app: stateful-app
    spec:
      containers:
        - name: app
          image: nginx:1.27
          ports:
            - containerPort: 80
          volumeMounts:
            - name: data
              mountPath: /data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 1Gi
</code></pre>
<p>The <code>volumeClaimTemplates</code> section creates a separate PersistentVolumeClaim for each StatefulSet Pod. Kubernetes names each claim using the template name and Pod identity, such as <code>data-stateful-app-0</code>. The claim remains available when the Pod is recreated, allowing the replacement Pod to use the same persistent storage.</p>
<h2>How to deploy a StatefulSet</h2>
<p>A StatefulSet needs a stable network identity and persistent storage for each Pod. The following manifest creates a headless Service and a StatefulSet with two Pods, each with its own PersistentVolumeClaim.</p>
<p>Create a file named <code>statefulset.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
  name: stateful-app
spec:
  clusterIP: None
  selector:
    app: stateful-app
  ports:
    - port: 80
      targetPort: 80

---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: stateful-app
spec:
  serviceName: stateful-app
  replicas: 2
  selector:
    matchLabels:
      app: stateful-app
  template:
    metadata:
      labels:
        app: stateful-app
    spec:
      containers:
        - name: app
          image: nginx:1.27
          ports:
            - containerPort: 80
              name: http
          volumeMounts:
            - name: app-data
              mountPath: /usr/share/nginx/html

  volumeClaimTemplates:
    - metadata:
        name: app-data
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 1Gi
</code></pre>
<p>The <code>serviceName</code> connects the StatefulSet to the headless Service, while <code>volumeClaimTemplates</code> tells Kubernetes to create a separate PVC for each Pod.</p>
<p>Apply the manifest:</p>
<pre><code class="language-bash">kubectl apply -f statefulset.yaml
</code></pre>
<p>Check that the StatefulSet was created:</p>
<pre><code class="language-bash">kubectl get statefulset
</code></pre>
<p>You should see the StatefulSet with two desired replicas:</p>
<pre><code class="language-text">NAME            READY   AGE
stateful-app    2/2     ...
</code></pre>
<p>Check the Pods:</p>
<pre><code class="language-bash">kubectl get pods
</code></pre>
<p>Kubernetes should create predictable Pod names based on the StatefulSet name and ordinal:</p>
<pre><code class="language-text">NAME              READY   STATUS    RESTARTS   AGE
stateful-app-0    1/1     Running   0          ...
stateful-app-1    1/1     Running   0          ...
</code></pre>
<p>Finally, check the PersistentVolumeClaims:</p>
<pre><code class="language-bash">kubectl get pvc
</code></pre>
<p>You should see a separate PVC for each Pod:</p>
<pre><code class="language-text">NAME                     STATUS   VOLUME   CAPACITY   ACCESS MODES
app-data-stateful-app-0  Bound    ...      1Gi        RWO
app-data-stateful-app-1  Bound    ...      1Gi        RWO
</code></pre>
<p>If both PVCs show <code>Bound</code>, Kubernetes has successfully provisioned persistent storage for the StatefulSet Pods.</p>
<blockquote>
<p><strong>Note:</strong> The PVCs depend on dynamic volume provisioning in your cluster. If no suitable StorageClass is available, the PVCs can remain in <code>Pending</code> instead of becoming <code>Bound</code>.</p>
</blockquote>
<h2>Apply best practices for stateful applications</h2>
<p>A working StatefulSet is not enough for a production workload. You also need to configure storage, resource usage, health checks, and data recovery appropriately.</p>
<p>Apply these practices when running stateful applications on Kubernetes:</p>
<ol>
<li>
<p><strong>Choose storage based on the application&#x27;s requirements.</strong> Use a StorageClass that provides the performance and availability your application needs. A StatefulSet does not make the underlying storage system resilient by itself.</p>
</li>
<li>
<p><strong>Set CPU and memory requests and limits.</strong> Define resource requirements for each container so Kubernetes can schedule the workload reliably and prevent it from consuming resources needed by other applications.</p>
</li>
<li>
<p><strong>Configure health checks carefully.</strong> Use readiness and liveness probes that match the application&#x27;s startup and recovery behavior. Aggressive probes can restart an application before it has enough time to become ready.</p>
</li>
<li>
<p><strong>Keep backups separate from persistent storage.</strong> A PVC preserves data when a Pod is recreated, but it is not a backup. Use a separate backup strategy and regularly test that the data can be restored.</p>
</li>
<li>
<p><strong>Test recovery before production use.</strong> Delete a Pod and verify that it reconnects to its existing PVC. Also test application recovery and backup restoration so you know how the workload behaves when something fails.</p>
</li>
</ol>
<p>These practices complement the StatefulSet rather than replacing it. The StatefulSet provides stable Pod identities and persistent storage, while storage configuration, backups, health checks, and recovery procedures determine how reliably the application operates in production.</p>
<h2>How to verify the StatefulSet</h2>
<p>Once the StatefulSet is running, verify that Kubernetes maintains the Pod&#x27;s identity and persistent storage. Test this by writing data to one Pod, deleting it, and confirming that the recreated Pod can still access the data.</p>
<p>First, confirm that both Pods are running:</p>
<pre><code class="language-bash">kubectl get pods
</code></pre>
<p>You should see both Pods in the <code>Running</code> state:</p>
<pre><code class="language-text">NAME              READY   STATUS    RESTARTS   AGE
stateful-app-0    1/1     Running   0          ...
stateful-app-1    1/1     Running   0          ...
</code></pre>
<p>Next, verify that Kubernetes created a separate PersistentVolumeClaim (PVC) for each Pod:</p>
<pre><code class="language-bash">kubectl get pvc
</code></pre>
<p>Both PVCs should show a <code>Bound</code> status:</p>
<pre><code class="language-text">NAME                     STATUS   VOLUME   CAPACITY   ACCESS MODES
app-data-stateful-app-0  Bound    ...      1Gi        RWO
app-data-stateful-app-1  Bound    ...      1Gi        RWO
</code></pre>
<p>Now write test data to the persistent volume mounted by <code>stateful-app-0</code>:</p>
<pre><code class="language-bash">kubectl exec stateful-app-0 -- sh -c &#x27;echo &quot;StatefulSet data&quot; &gt; /usr/share/nginx/html/data.txt&#x27;
</code></pre>
<p>Verify that the file was created:</p>
<pre><code class="language-bash">kubectl exec stateful-app-0 -- cat /usr/share/nginx/html/data.txt
</code></pre>
<p>The command should return:</p>
<pre><code class="language-text">StatefulSet data
</code></pre>
<p>Now delete <code>stateful-app-0</code>:</p>
<pre><code class="language-bash">kubectl delete pod stateful-app-0
</code></pre>
<p>The StatefulSet controller should recreate the Pod with the same ordinal name. Check its status:</p>
<pre><code class="language-bash">kubectl get pods
</code></pre>
<p>You should see <code>stateful-app-0</code> running again:</p>
<pre><code class="language-text">NAME              READY   STATUS    RESTARTS   AGE
stateful-app-0    1/1     Running   0          ...
stateful-app-1    1/1     Running   0          ...
</code></pre>
<p>Finally, verify that the test data is still available:</p>
<pre><code class="language-bash">kubectl exec stateful-app-0 -- cat /usr/share/nginx/html/data.txt
</code></pre>
<p>If the recreated Pod does not reach the Running state, check its events and container logs to identify the failure. For a deeper walkthrough of diagnosing this condition, see <a href="https://reclear.io/blog/how-to-fix-crashloopbackoff-in-kubernetes">How to Fix the &quot;CrashLoopBackOff&quot; Error in Kubernetes</a>.</p>
<p>The command should return:</p>
<pre><code class="language-text">StatefulSet data
</code></pre>
<p>The data remains available because the recreated Pod reconnects to the existing PVC instead of receiving a new empty volume. This confirms that the StatefulSet maintains the Pod&#x27;s stable identity and persistent storage across Pod recreation.</p>
<h2>When this approach breaks down</h2>
<p>A StatefulSet is useful when an application needs stable Pod identities or persistent storage, but it is not the right workload controller for every application.</p>
<ul>
<li><strong>The application is stateless.</strong> If Pods can be replaced without preserving their identity or data, a Deployment is simpler and avoids unnecessary StatefulSet configuration.</li>
<li><strong>The storage layer is not highly available.</strong> A StatefulSet manages the relationship between Pods and their PVCs, but it does not make the underlying storage system resilient. Storage availability still depends on the cluster&#x27;s StorageClass and storage infrastructure.</li>
<li><strong>The application needs data replication or backups.</strong> A StatefulSet provides stable identities and persistent volumes, but it does not automatically replicate application data or create backups. Databases and distributed systems still need their own replication, backup, and recovery mechanisms.</li>
</ul>
<p>For simple stateful workloads, a StatefulSet with persistent storage may be enough. For production databases and distributed systems, you still need separate strategies for replication, backups, and recovery.</p>]]></content:encoded>
      <pubDate>Tue, 25 Aug 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Abdul Talha)</author>
      <category>Tutorials</category>
      <category>Kubernetes</category>
      <category>CloudNative</category>
      <category>DevOps</category>
      <enclosure url="https://reclear.io/blog/how-to-deploy-a-stateful-application-on-kubernetes/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Terraform Security Scanning in GitHub Actions</title>
      <link>https://reclear.io/blog/terraform-security-scanning-github-actions</link>
      <guid isPermaLink="true">https://reclear.io/blog/terraform-security-scanning-github-actions</guid>
      <description>Add Terraform security scanning to GitHub Actions with Trivy, fail pull requests on high-risk misconfigurations, and keep approved infrastructure changes reviewable.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/terraform-security-scanning-github-actions/cover/cover.webp" alt="Terraform Security Scanning in GitHub Actions" /></p><aside><ul>
<li>Terraform security scanning in GitHub Actions catches insecure infrastructure before <code>terraform apply</code>, when the change is still a pull request.</li>
<li>Trivy <code>0.61.0</code> can scan Terraform files for misconfigurations without cloud credentials or a live account.</li>
<li>A useful gate fails on high and critical findings, while a separate ignore file documents narrowly scoped exceptions.</li>
<li>Static scanning is not a replacement for plan review, provider policy, or runtime cloud configuration checks.</li>
</ul></aside>
<p>A Terraform pull request can pass formatting, type checks, and even <code>terraform validate</code> while still opening a storage bucket to the public internet or allowing SSH from <code>0.0.0.0/0</code>. Those checks confirm that Terraform can understand the configuration. They do not decide whether the requested infrastructure is safe to deploy.</p>
<p>This article builds a Terraform security scanning gate in GitHub Actions with Trivy <code>0.61.0</code>, then verifies that an intentionally unsafe resource fails the workflow before it can reach <code>terraform plan</code> or <code>terraform apply</code>.</p>
<aside><p><strong>What you need:</strong> Terraform CLI <code>1.9.8</code>, Docker <code>27.3.1</code> or later, Git, and a GitHub repository where you can add workflows. The example uses local Terraform files only, so it does not need cloud credentials.</p></aside>
<h2>Why Terraform security scanning in GitHub Actions matters</h2>
<p><code>terraform fmt</code> checks formatting, and <code>terraform validate</code> checks configuration syntax and type constraints. Neither command evaluates common cloud security risks. A configuration can be valid Terraform and still violate the security boundary your team expects.</p>
<p>The <a href="https://developer.hashicorp.com/terraform/cli/commands/validate">Terraform validation documentation</a> describes validation as a syntax and internal-consistency check. Treat it as one layer in the pipeline, not as a security scanner.</p>
<table><thead><tr><th>Check</th><th>Finds</th><th>Does not find</th></tr></thead><tbody><tr><td><code>terraform fmt</code></td><td>Formatting drift</td><td>Insecure resource settings</td></tr><tr><td><code>terraform validate</code></td><td>Invalid references and types</td><td>Public exposure or weak encryption defaults</td></tr><tr><td>Trivy config scan</td><td>Known IaC misconfigurations</td><td>Runtime drift after deployment</td></tr><tr><td><code>terraform plan</code> review</td><td>The proposed resource changes</td><td>Every provider-specific security policy</td></tr></tbody></table>
<p>The failure mode is a green pipeline that proves only that an unsafe request is well-formed. The fix is to put a policy-aware scan before the deployment job and make its failure behavior explicit.</p>
<h2>Build an unsafe Terraform fixture</h2>
<p>A reproducible fixture makes the gate testable. The following module creates an Amazon S3 bucket with public access enabled and a security group that permits SSH from every IPv4 address. Do not apply this configuration to an AWS account.</p>
<p>Create <code>examples/insecure/main.tf</code> with these resources:</p>
<pre><code class="language-hcl">terraform {
  required_version = &quot;&gt;= 1.9.8, &lt; 2.0.0&quot;

  required_providers {
    aws = {
      source  = &quot;hashicorp/aws&quot;
      version = &quot;~&gt; 5.70&quot;
    }
  }
}

provider &quot;aws&quot; {
  region = &quot;us-east-1&quot;
}

resource &quot;aws_s3_bucket&quot; &quot;artifacts&quot; {
  bucket = &quot;replace-with-a-unique-bucket-name&quot;
}

resource &quot;aws_s3_bucket_public_access_block&quot; &quot;artifacts&quot; {
  bucket = aws_s3_bucket.artifacts.id

  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = true
  restrict_public_buckets = false
}

resource &quot;aws_security_group&quot; &quot;admin&quot; {
  name = &quot;insecure-admin&quot;

  ingress {
    description = &quot;SSH from anywhere&quot;
    protocol    = &quot;tcp&quot;
    from_port   = 22
    to_port     = 22
    cidr_blocks = [&quot;0.0.0.0/0&quot;]
  }
}
</code></pre>
<p>The example is deliberately unsafe, but it is also syntactically valid. Initialize and validate it locally:</p>
<pre><code class="language-bash">cd examples/insecure
terraform init -backend=false
terraform fmt -check
terraform validate
</code></pre>
<p><code>terraform validate</code> should exit successfully. That result demonstrates why syntax validation alone cannot be the security gate.</p>
<h2>Add the Trivy workflow</h2>
<p>The workflow should scan the repository before any job that can authenticate to a cloud account. Pin the scanner image instead of using a floating <code>latest</code> tag, and keep the workflow permission set minimal.</p>
<p>Create <code>.github/workflows/terraform-security.yml</code>:</p>
<pre><code class="language-yaml">name: Terraform security

on:
  pull_request:
    paths:
      - &quot;**/*.tf&quot;
      - &quot;.trivyignore&quot;
      - &quot;.github/workflows/terraform-security.yml&quot;
  push:
    branches: [main]
    paths:
      - &quot;**/*.tf&quot;
      - &quot;.trivyignore&quot;
      - &quot;.github/workflows/terraform-security.yml&quot;

permissions:
  contents: read

jobs:
  misconfiguration:
    name: Scan Terraform configuration
    runs-on: ubuntu-24.04
    steps:
      - name: Check out repository
        uses: actions/checkout@v4

      - name: Scan infrastructure as code
        uses: aquasecurity/trivy-action@0.28.0
        with:
          scan-type: config
          scan-ref: .
          trivy-version: &quot;v0.61.0&quot;
          severity: HIGH,CRITICAL
          exit-code: &quot;1&quot;
          ignore-unfixed: true
</code></pre>
<p><code>scan-type: config</code> tells Trivy to inspect configuration rather than container images. <code>severity</code> limits the gate to findings your team has decided are release-blocking, and <code>exit-code: &quot;1&quot;</code> converts a finding into a failed job.</p>
<p>Run the same scanner locally before opening a pull request. The Docker command uses the pinned image and mounts the repository read-only:</p>
<pre><code class="language-bash">docker run --rm \
  -v &quot;$PWD:/workspace:ro&quot; \
  -w /workspace \
  aquasec/trivy:0.61.0 \
  config --severity HIGH,CRITICAL --exit-code 1 .
</code></pre>
<p>The command should report findings for the public S3 access and unrestricted SSH rule, then exit with status <code>1</code>. No AWS credentials should be present for this scan.</p>
<h2>How to fix it</h2>
<p>The scanner identifies risk, but the pull request still needs an engineering fix. Apply the fixes in the same order you would review them in production.</p>
<ol>
<li>Block public access on the bucket. Keep the default-deny controls enabled and add an explicit bucket policy only for the identities that need access.</li>
<li>Replace <code>0.0.0.0/0</code> on administrative ports with a private network range or a security-group reference. If temporary public access is unavoidable, make it time-bound and monitored.</li>
<li>Run <code>terraform fmt -check</code>, <code>terraform validate</code>, and the security scan together. A scan that runs only after <code>terraform apply</code> is a report, not a preventive control.</li>
<li>Review the Terraform plan in a separate job with cloud credentials. Keep credentials out of the static scan job because the scanner does not need them.</li>
</ol>
<p>The following safer security group rule illustrates the boundary without requiring a real network:</p>
<pre><code class="language-hcl">resource &quot;aws_security_group&quot; &quot;admin&quot; {
  name = &quot;private-admin&quot;

  ingress {
    description     = &quot;SSH from the operations network&quot;
    protocol        = &quot;tcp&quot;
    from_port       = 22
    to_port         = 22
    cidr_blocks     = [&quot;10.20.0.0/16&quot;]
    security_groups = []
  }
}
</code></pre>
<p>Keep the scanner job independent from the deploy job. That separation follows the same short-lived-credential principle described in <a href="https://reclear.io/blog/oidc-for-cicd-eliminate-long-lived-cloud-keys">the OIDC guide for GitHub Actions</a>: a job should receive only the permissions it needs for its own work.</p>
<h2>How to verify the gate blocks unsafe changes</h2>
<p>A security gate is useful only when you can prove both failure and success behavior. Run the checks locally, then use the pull request workflow as the final integration test.</p>
<p>Use this sequence from the repository root:</p>
<pre><code class="language-bash">terraform -chdir=examples/insecure init -backend=false
terraform -chdir=examples/insecure fmt -check
terraform -chdir=examples/insecure validate
docker run --rm \
  -v &quot;$PWD:/workspace:ro&quot; \
  -w /workspace \
  aquasec/trivy:0.61.0 \
  config --severity HIGH,CRITICAL --exit-code 1 .
</code></pre>
<p>The first three commands should pass. The Trivy command should fail and print the finding IDs. After replacing the unsafe fixture or adding a narrowly justified exception, rerun the command and confirm it exits with status <code>0</code>.</p>
<p>If the workflow passes unexpectedly, check that the scanner is reading the directory containing the <code>.tf</code> files, that <code>severity</code> is not excluding the finding, and that <code>exit-code</code> is set to <code>1</code>. Also confirm that the workflow is not marked <code>continue-on-error</code>.</p>
<h2>When this approach breaks down</h2>
<p>Static Terraform scanning is an early control, not the entire infrastructure security program.</p>
<ul>
<li>A scanner cannot evaluate every value produced by remote state, data sources, or provider APIs. Scan the rendered plan or enforce policy at the deployment boundary for those cases.</li>
<li>Rules can produce false positives or miss provider-specific behavior. Keep provider policy, code review, and runtime cloud posture checks in the pipeline.</li>
<li>An ignore file can turn a real vulnerability into a green build. Require an owner, reason, and expiration review for every exception instead of ignoring by broad directory or rule family.</li>
</ul>
<p>Trivy findings also depend on its bundled rules and version. Pin the version, review upgrades, and treat changes in scanner behavior as a dependency update that deserves a pull request.</p>
<h2>Frequently asked questions</h2>
<h3>Does <code>terraform validate</code> replace a security scanner?</h3>
<p>No. Validation checks whether Terraform can parse and type-check the configuration. It does not decide whether a resource is publicly exposed, weakly encrypted, or overly permissive.</p>
<h3>Should the scan run on pull requests from forks?</h3>
<p>Yes, if the job needs only repository contents and no secrets. Keep cloud credentials and apply permissions in separate jobs, and do not expose those credentials to untrusted pull request code.</p>
<h3>How should accepted findings be documented?</h3>
<p>Use the narrowest supported exception, explain the compensating control, assign an owner, and set an expiration date. An exception without an expiry becomes an undocumented permanent policy change.</p>]]></content:encoded>
      <pubDate>Sat, 22 Aug 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Ilyas Rufai)</author>
      <category>Tutorials</category>
      <category>DevSecOps</category>
      <category>Terraform</category>
      <category>GitHubActions</category>
      <enclosure url="https://reclear.io/blog/terraform-security-scanning-github-actions/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Developer Documentation Is a Product Feature, Not an Afterthought</title>
      <link>https://reclear.io/blog/developer-documentation-is-a-product-feature-not-an-afterthought</link>
      <guid isPermaLink="true">https://reclear.io/blog/developer-documentation-is-a-product-feature-not-an-afterthought</guid>
      <description>Discover how treating developer documentation as a core product feature reduces support overhead, speeds up onboarding, and prevents technical debt.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/developer-documentation-is-a-product-feature-not-an-afterthought/cover/cover.webp" alt="Developer Documentation Is a Product Feature, Not an Afterthought" /></p><aside><ul>
<li>Developer documentation is part of the product experience. For most APIs, it shapes the first impression long before a developer reaches the dashboard.</li>
<li>Writing documentation alongside implementation exposes missing workflows, inconsistent naming, and hidden design assumptions while they&#x27;re still inexpensive to fix.</li>
<li>Documentation reduces far more than support tickets. It shortens time-to-value, preserves institutional knowledge, and enables engineering teams to scale without scaling onboarding effort.</li>
<li>Documentation quality is rarely a writing problem. It is usually a workflow problem. Teams that treat documentation like source code produce documentation that stays accurate as the product evolves.</li>
</ul></aside>
<p>A team ships a new API endpoint, updates the changelog, and defers documentation to the next sprint. By the time the docs catch up, the endpoint has gained new parameters and error codes. While monitoring dashboards report 100% uptime, developers integrating against stale documentation hit silent failures leading to abandoned integrations, repeated support tickets, and lost trust.
In this article, we examine why developer documentation must be treated as an engineering system, and how building docs into your development lifecycle prevents them from becoming technical debt.</p>
<h2>Why documentation gets treated as an afterthought</h2>
<p>Documentation loses the prioritization fight for a structural reason: it doesn&#x27;t have its own bug tracker. A broken endpoint blocks a release. A missing rate-limit header shows up in a customer&#x27;s error logs within hours. A wrong parameter type in the docs shows up nowhere in your monitoring, because your monitoring watches the code, not the sentence describing the code. The cost is real, but it lands on the developer reading the docs, not on the team that shipped them, so it&#x27;s easy to defer.</p>
<p>That deferral compounds. A stale doc doesn&#x27;t just fail to help; it actively misleads, which is worse than no documentation at all. A developer who hits a dead end on an undocumented feature knows to ask. A developer who follows a documented step that no longer matches the API spends an hour convinced the bug is in their own code before they consider that the docs might be wrong.</p>
<p>Documentation debt accumulates faster than technical debt because developers trust documentation longer than they trust code. Technical debt eventually announces itself through failing tests, production incidents, or slowing delivery. Documentation debt is quieter. Developers assume the documentation is correct until repeated failures convince them otherwise. By then, the problem is no longer a missing paragraph. It&#x27;s a loss of trust, and trust takes considerably longer to rebuild than documentation takes to update.</p>
<h2>Documentation defines time-to-value</h2>
<p>For a developer evaluating a new tool, every integration decision comes down to whether to invest more time or abandon it. That decision doesn&#x27;t start at the dashboard. It starts at the getting-started guide, because that&#x27;s the first thing a developer actually reads before writing a line of code against your API.</p>
<p>High-quality documentation gets a developer to a successful first request in minutes. Documentation that buries the authentication example three pages deep, or skips straight to the reference before showing a working call, turns that same evaluation into an hour of guesswork.</p>
<p>Two companies can build equally capable APIs and see dramatically different adoption rates because developers rarely evaluate an API in isolation. They evaluate the entire onboarding experience. The quality of the documentation often determines whether an API feels approachable or unnecessarily complex before a single production request is ever sent.</p>
<p>Our <a href="https://reclear.io/blog/what-makes-great-api-documentation">breakdown of what separates great API documentation</a> covers what that first getting-started page needs to include to earn the next hour of a developer&#x27;s attention.</p>
<p>One lesson I&#x27;ve consistently seen across developer documentation projects is that developers almost never abandon an API because the technology is beyond them. They abandon integrations when they can no longer predict what happens next. Good documentation reduces implementation effort. Great documentation reduces uncertainty.</p>
<h2>Documentation shapes the product before it ships</h2>
<p>One recurring pattern I&#x27;ve noticed while documenting APIs is that writing often uncovers engineering decisions that implementation alone never exposes. Documentation forces a different kind of review because explaining a feature to another engineer requires answering questions that working code can quietly ignore. By the time you describe an endpoint&#x27;s behavior in plain language, assumptions that seemed obvious during implementation suddenly become difficult to justify.</p>
<p>Writing the docs alongside the build tends to expose three things before a developer outside the team ever encounters them:</p>
<ol>
<li>
<p><strong>Missing workflows</strong>: a documented flow that requires a step nobody implemented, because the implementation assumed a UI would handle it.</p>
</li>
<li>
<p><strong>Hidden assumptions and edge cases</strong>: what happens on a duplicate request, what the error response looks like for a validation failure, whether a field is nullable in practice or only in theory.</p>
</li>
<li>
<p><strong>Inconsistency across the product surface</strong>: if one endpoint returns <code>customer_id</code> and a newer one returns <code>customerId</code>, writing both up side by side in the same reference makes the mismatch visible in a way that reviewing two separate pull requests, months apart, does not.</p>
</li>
</ol>
<p>This is one reason I increasingly view documentation as an engineering activity instead of a publishing activity. Documentation doesn&#x27;t simply describe software after it&#x27;s built. It exposes whether the software is coherent enough to be understood before customers depend on it.</p>
<p>A technical writer or engineer trying to document a webhook retry policy who cannot answer &quot;what happens if the endpoint is down for six hours&quot; has found a real gap in the design, not a gap in the writing. That gap is far cheaper to close during review than after customers have already built retry logic around undocumented behavior.</p>
<h2>Documentation drives deflection and retention</h2>
<p>Developers default to self-serve problem-solving. Given a choice between reading a doc and opening a support ticket, they read the doc, provided the doc actually answers the question. Every question a developer can resolve from a tutorial, a reference spec, or a troubleshooting page is a ticket that never gets filed. This is the most direct financial argument for documentation as a product investment: it&#x27;s the mechanism that lets support scale with the user base instead of scaling with headcount.</p>
<aside><p>If a support channel answers the same question three times in a month, that&#x27;s a documentation gap, not a training gap. Route recurring support questions back to the docs backlog, not just to a canned response.</p></aside>
<p>Error documentation is where this pays off fastest. A developer who receives a <code>422</code> with a documented cause and a specific recovery step resolves it themselves. A developer who receives the same error with no explanation opens a ticket, waits for a reply, and forms an opinion about the product&#x27;s reliability while they wait.</p>
<p>The API behaved identically in both cases. Only the documentation changed the outcome. Our guide to <a href="https://reclear.io/blog/api-error-handling-best-practices">API error handling best practices</a> walks through what a documented error contract needs to include to actually deflect that ticket.</p>
<h2>Documentation is a strategic asset, not a manual</h2>
<p>For an API-first platform, documentation isn&#x27;t a support artifact. It&#x27;s the only channel through which the platform communicates capability, security posture, and architecture to a developer who has never spoken to anyone on the team.</p>
<p>Stripe is the clearest example of this: developers evaluate and adopt Stripe largely through its docs, because the docs are where the utility of the product actually gets demonstrated. If a developer cannot understand how to integrate with a service, the underlying technology is, for that developer, effectively unusable, regardless of how well it performs in production.</p>
<p>The same asset works internally, not just externally. Documentation functions as a centralized reference that preserves organizational knowledge: the architecture decisions, the reasons a workaround exists, the trade-offs behind a design choice. Without it, that context lives only in the heads of whoever built the system.</p>
<p>When a team member moves to a different project or leaves, the knowledge leaves with them, and the next engineer re-derives decisions that were already made once. Documentation written down as the product evolves is what makes onboarding a new hire a matter of reading, not a matter of interrupting three people with the same question.</p>
<h2>How to build documentation into the engineering lifecycle</h2>
<p>The question isn&#x27;t whether documentation should be maintained. The question is whether the process makes maintaining it inevitable. Maintaining documentation manually becomes increasingly difficult as products evolve. Updating every code reference by hand, chasing down every renamed field across a dozen pages, is not a discipline problem: it&#x27;s a process that doesn&#x27;t scale past a handful of engineers.</p>
<p>A useful way to think about this is through a <strong>Documentation Development Life Cycle</strong> (DDLC): a workflow that treats documentation as a parallel engineering process rather than a publishing task at the end of a release.</p>
<table><thead><tr><th>Engineering Lifecycle</th><th>Documentation Lifecycle</th></tr></thead><tbody><tr><td>Plan the feature</td><td>Define the documentation scope and audience</td></tr><tr><td>Implement the feature</td><td>Draft conceptual and task-based documentation alongside development</td></tr><tr><td>Review the code</td><td>Review documentation for accuracy, completeness, and clarity</td></tr><tr><td>Merge and release</td><td>Publish documentation with the release</td></tr><tr><td>Monitor production</td><td>Update documentation based on developer feedback, support trends, and product changes</td></tr></tbody></table>
<p>The important shift isn&#x27;t adding another process. It&#x27;s ensuring documentation reaches every stage where engineering decisions are made. By the time a feature reaches production, the documentation should already have gone through the same planning, review, and release discipline as the code itself.</p>
<p>Teams don&#x27;t need to adopt every documentation practice at once. The biggest gains usually come from introducing a few workflow changes that keep documentation moving with the product instead of chasing it after release. These include:</p>
<ol>
<li>
<p>Integrating documentation review into the pull request workflow, so that a documentation change is reviewed with the same rigor as a code change, by the same team.</p>
</li>
<li>
<p>Using version control to track documentation alongside source code, in the same repository or a tightly linked one.</p>
</li>
<li>
<p>Requiring that a PR changing user-facing behavior includes the corresponding documentation update before merge. This workflow has become widely known as <a href="https://www.writethedocs.org/guide/docs-as-code/">docs-as-code</a>: documentation lives in version control, follows the same review process as source code, and ships through the same delivery pipeline.</p>
</li>
<li>
<p>Leveraging automated API documentation generation from an <a href="https://spec.openapis.org/oas/latest.html">OpenAPI specification</a>, source code annotations, or SDK comments keeps reference material aligned with the API surface it describes instead of relying on manual updates.</p>
</li>
<li>
<p>Creating reusable content components and single-sourced references, so a change to a shared concept (an auth flow, a rate-limit policy) updates once and propagates everywhere it&#x27;s referenced, instead of needing to be found and fixed across a dozen pages.</p>
</li>
</ol>
<p>This distinction also explains why documentation quality rarely depends on writing skill alone. A technical writer improves the clarity, structure, and usability of content. A documentation engineer improves the systems that keep that content accurate as the product evolves. One focuses primarily on communication. The other focuses on sustainability. Mature documentation teams eventually need both.</p>
<p>The other piece is ownership. Docs-as-code and generation pipelines reduce the manual burden, but they don&#x27;t remove the need for someone to be accountable when a change ships without its documentation. Establishing that a code change affecting users requires a corresponding documentation update, as a review requirement, is what makes the rest of the system hold.</p>
<h2>What this looks like in practice</h2>
<p>These ideas aren&#x27;t theoretical. Many of the developer platforms considered best-in-class today reached that reputation because documentation became part of engineering rather than an activity that followed it. A few concrete patterns show what treating documentation as a product feature actually changes:</p>
<ol>
<li>
<p>Kubernetes treats documentation as part of the engineering workflow. Its <a href="https://kubernetes.io/docs/">documentation</a> lives alongside the project&#x27;s <a href="https://github.com/kubernetes/website">source code</a>, and documentation pull requests go through the same Git-based review process as code contributions. That shared workflow makes documentation maintenance part of engineering rather than an activity that happens after release.</p>
</li>
<li>
<p>Stripe remains one of the strongest examples of documentation functioning as part of the product experience. Many developers evaluate Stripe through its <a href="https://docs.stripe.com/">documentation</a> before they ever build against the API, making the documentation itself a significant driver of adoption, onboarding, and developer confidence.</p>
</li>
<li>
<p><a href="https://docs.github.com/en/rest">GitHub&#x27;s REST API documentation</a> demonstrates another advantage of treating API descriptions as structured data. Much of the reference documentation is generated from the same underlying API description, reducing the likelihood that endpoint parameters or response fields drift away from the implementation.</p>
</li>
</ol>
<p>The common thread: documentation quality is a byproduct of where documentation sits in the workflow, not of how skilled the person writing it is. A brilliant writer working outside the engineering pipeline will still watch their work go stale. A junior engineer contributing documentation inside a docs-as-code workflow, reviewed alongside the code, will produce documentation that survives the next release.</p>
<h2>When this breaks down</h2>
<p>Treating documentation as a product feature solves the drift and prioritization problem. It doesn&#x27;t solve every documentation problem on its own.</p>
<ol>
<li>
<p>Docs-as-code lowers the barrier for developers to contribute, but it raises the barrier for non-technical stakeholders. A product manager who wants to fix a paragraph now has to understand Git and a pull request review, unless the team invests separately in a visual editor or a lighter contribution path.</p>
</li>
<li>
<p>Generating reference documentation from a spec keeps endpoint tables accurate, but it produces reference material, not conceptual guides or tutorials. A spec can&#x27;t explain why a team chose polling over webhooks, and it can&#x27;t write the &quot;what to do when this breaks&quot; section a developer actually needs.</p>
</li>
<li>
<p>Automated checks catch drift, not quality. A pipeline can confirm a docs file changed alongside a route file; it cannot confirm the new sentence explaining the route is clear, accurate, or complete. That still requires a human review step with the same seriousness as a code review.</p>
</li>
</ol>
<p>The strongest documentation programs treat these trade-offs as engineering constraints to manage rather than reasons to abandon the model. A docs-as-code workflow with lightweight contribution paths, generated reference documentation, and deliberate editorial review closes most of the gap.</p>
<p>Ultimately, documentation doesn&#x27;t determine whether software works. It determines whether other developers can confidently make that software work for them. That&#x27;s why documentation isn&#x27;t simply a product feature. It&#x27;s one of the clearest indicators of engineering quality.</p>
<p>For teams selling technical products, that indicator becomes part of the buying experience. Strong documentation does not just explain the product after a decision is made. It helps developers, product leaders, and engineering stakeholders decide whether the product is trustworthy enough to adopt.</p>
<aside><p>If your documentation needs to do more than answer support questions, Reclear
helps turn complex technical products into clear, developer-friendly content
that supports adoption.</p></aside>
<p><a href="https://reclear.io/book-a-call">Work with us</a>: Book a call to discuss how Reclear can help improve your developer documentation and technical content.</p>]]></content:encoded>
      <pubDate>Thu, 20 Aug 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (AbdulRaheem Olurode)</author>
      <category>ContentStrategy</category>
      <category>DeveloperExperience</category>
      <category>Documentation</category>
      <enclosure url="https://reclear.io/blog/developer-documentation-is-a-product-feature-not-an-afterthought/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Docs as Code: Benefits, Challenges, and Best Practices</title>
      <link>https://reclear.io/blog/docs-as-code-benefits-challenges-and-best-practices</link>
      <guid isPermaLink="true">https://reclear.io/blog/docs-as-code-benefits-challenges-and-best-practices</guid>
      <description>Learn how Docs as Code helps you manage documentation with code, simplify reviews, track changes, and keep content accurate and up to date.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/docs-as-code-benefits-challenges-and-best-practices/cover/cover.webp" alt="Docs as Code: Benefits, Challenges, and Best Practices" /></p><aside><ul>
<li>Docs as Code treats documentation like code, using version control, reviews, testing, and automation.</li>
<li>It brings writers and developers into the same workflow, making it easier to review and update documentation.</li>
<li>The approach has drawbacks, including a learning curve, workflow changes, documentation drift, and existing content migration.</li>
<li>Start with a workflow your team can maintain, using clear ownership, pull request reviews, and automated checks.</li>
</ul></aside>
<p>A developer ships a new feature, but the documentation still shows the previous behavior. When the documentation is updated later, reviewers have to work through a separate process to compare the changes, determine whether the documentation still matches the product, and fix issues such as broken links or invalid formatting.</p>
<p>In this article, you&#x27;ll look at how Docs as Code helps teams keep documentation connected to development, review changes more effectively, and fix documentation issues before they are published.</p>
<h2>What Is Docs as Code</h2>
<p>Docs as Code is a process to manage documentation using the same development practices used to manage code. Instead of treating documentation as a separate publishing task, teams manage it through version control, reviews, automated checks, and development workflows.</p>
<p>The approach connects documentation changes to the work happening in the product. A documentation update can be tracked, reviewed through a pull request, and merged before it is published. Code and documentation can live in the same repository or in separate repositories. What matters is that the documentation changes are visible and reviewable.</p>
<p>A typical Docs-as-Code workflow uses Git for version control, Markdown or another plain-text format for writing, pull requests for review, and automated checks for issues such as broken links or build failures. A publishing pipeline can then build and deploy the documentation after the changes are approved.</p>
<p>Docs as Code is a workflow, not a specific tool or repository structure. You don&#x27;t need to use a particular documentation platform, keep code and documentation in the same repository, or keep every documentation change in the same pull request. The important part is using development practices that make documentation easier to change, review, and maintain.</p>
<h2>Why Use Docs as Code</h2>
<p>Docs as Code helps teams keep documentation closer to product changes. <a href="https://reclear.io/blog/developer-documentation-is-a-product-feature-not-an-afterthought">Documentation is part of the product experience</a>, so keeping it aligned with product changes matters. When documentation follows the development workflow, teams can track and review documentation changes alongside the work that changes the product. This reduces the gap between what the product does and what the documentation describes.</p>
<p>It also makes documentation changes easier to review. With version control and pull requests, reviewers can see exactly what changed, comment on specific updates, and approve the documentation before it is merged. They don&#x27;t have to compare separate document versions or review changes through a separate publishing workflow.</p>
<p>Automated checks can catch mechanical problems before documentation is published. Teams can check for broken links, invalid Markdown, build failures, and similar issues as part of the review process. These checks don&#x27;t replace human review, but they can catch problems that are easy to miss manually.</p>
<p>Docs as Code also gives developers and writers a shared workflow for contributing to documentation. Developers can propose changes when they find an issue, while writers can review the content for clarity and consistency. This makes documentation easier to maintain as the product changes.</p>
<h2>How the Workflow Works</h2>
<p>A Docs-as-Code workflow follows a cycle: update the documentation, track the change, review it, run checks, and publish it. The exact tools can vary, but the workflow usually looks like this:</p>
<ol>
<li>
<p><strong>Write or update the documentation:</strong> Update the documentation when a product behavior, API, configuration, or feature changes.</p>
</li>
<li>
<p><strong>Commit the changes to version control:</strong> Store the documentation in <a href="https://git-scm.com/docs">Git</a> so the team can see what changed and recover earlier versions when needed.</p>
</li>
<li>
<p><strong>Open a pull request:</strong> Use the <a href="https://docs.github.com/en/pull-requests/get-started/about-pull-requests">pull request</a> to show the changes and give developers, writers, and other reviewers a place to comment on them.</p>
</li>
<li>
<p><strong>Run automated checks:</strong> Use <a href="https://docs.github.com/en/actions/get-started/understand-github-actions">GitHub Actions</a> to check for problems such as broken links, invalid Markdown, or documentation build failures before merging the change.</p>
</li>
<li>
<p><strong>Review and merge the change:</strong> Reviewers verify that the documentation matches the product and follows the team&#x27;s documentation standards.</p>
</li>
<li>
<p><strong>Build and publish the documentation:</strong> After the change is merged, the documentation pipeline can build and publish the updated content.</p>
</li>
</ol>
<p>The workflow can be easier to understand when you follow one documentation change from start to finish:</p>
<p><img src="https://reclear.io/blog/docs-as-code-benefits-challenges-and-best-practices/diagram/docs-as-code-workflow.webp" alt="Docs as Code workflow showing a documentation change from branch creation through editing, pull-request review, automated validation, and deployment."/></p>
<p>For example, a writer can create a branch for a documentation update, edit the content, and open a pull request. Reviewers can then check the changes while automated validation looks for mechanical problems. Once the change is approved and merged, the documentation pipeline can build and deploy the updated content.</p>
<p>The code and documentation don&#x27;t have to live in the same repository or change in the same pull request. What matters is that documentation follows a workflow where changes are tracked, reviewed, validated, and published instead of being maintained through an isolated process.</p>
<h2>What Tools Support the Workflow?</h2>
<p>Docs as Code doesn&#x27;t depend on a specific tools. Teams combine different tools to write, manage, review, validate, and publish documentation.</p>
<ol>
<li><strong>Version control:</strong> Git tracks documentation changes and keeps a history of previous versions. Teams can use Git with platforms such as GitHub or GitLab to collaborate on those changes.</li>
<li><strong>Authoring formats:</strong> Markdown, AsciiDoc, and reStructuredText. Teams store documentation as plain-text files that can be managed in version control. Tools such as Docusaurus and MkDocs can use these files to build documentation sites.</li>
<li><strong>Review tools:</strong> <a href="https://docs.github.com/en/pull-requests">GitHub Pull Requests</a> and <a href="https://docs.gitlab.com/user/project/merge_requests/">GitLab Merge Requests</a>. Reviewers see documentation diffs, leave comments, and approve changes before they are merged.</li>
<li><strong>CI/CD:</strong> <a href="https://docs.github.com/en/actions">GitHub Actions</a>, <a href="https://docs.gitlab.com/ci/">GitLab CI/CD</a>, and <a href="https://www.jenkins.io/doc/">Jenkins</a> can run documentation checks, build the site, and publish changes after they pass the required checks.</li>
<li><strong>Documentation platforms:</strong> Tools such as <a href="https://docusaurus.io/docs">Docusaurus</a>, <a href="https://www.mkdocs.org/">MkDocs</a>, <a href="https://gohugo.io/documentation/">Hugo</a>, <a href="https://docs.github.com/en/pages">GitHub Pages</a>, and Read the Docs can handle different parts of building and delivering documentation.</li>
</ol>
<p>The exact tools vary by team. Docs as Code is defined by the workflow, not by a particular Git provider, documentation generator, or publishing platform.</p>
<h2>Challenges of Docs as Code</h2>
<p>Docs as Code can make documentation easier to track and review, but the workflow also introduces its own challenges. Teams need to consider who will contribute, how changes will be reviewed, and how much tooling they can maintain.</p>
<ol>
<li>
<p><strong>Git and Markdown have a learning curve:</strong> Contributors who are used to visual editors may need to learn Git, Markdown, pull requests, and other development practices before they can contribute comfortably.</p>
</li>
<li>
<p><strong>Nontechnical contributors may face friction:</strong> A workflow built around branches, commits, and pull requests can make documentation harder to update for subject matter experts who don&#x27;t regularly use development tools. Teams may need simpler contribution paths or support for these contributors.</p>
</li>
<li>
<p><strong>Documentation can still drift from the product:</strong> Version control makes documentation changes traceable, but it doesn&#x27;t guarantee that the content stays accurate. Documentation can still become outdated when product changes don&#x27;t include the corresponding documentation work.</p>
</li>
<li>
<p><strong>Merge conflicts and review bottlenecks can slow changes:</strong> Documentation changes can conflict when multiple contributors edit the same files. Reviews can also become a bottleneck when every change requires approval from a small group of reviewers.</p>
</li>
<li>
<p><strong>Existing documentation can be difficult to migrate:</strong> Moving documentation from a CMS, wiki, or another publishing system into a Docs-as-Code workflow can require converting content, restructuring files, preserving links, and adapting existing publishing processes.</p>
</li>
<li>
<p><strong>The toolchain adds maintenance work:</strong> A Docs-as-Code setup can involve version control, authoring formats, documentation generators, CI checks, hosting, and deployment pipelines. Each part needs to be configured and maintained as the documentation system evolves.</p>
</li>
</ol>
<p>These challenges don&#x27;t mean Docs as Code won&#x27;t work. They show why the workflow needs to match the team&#x27;s contributors, documentation needs, and ability to maintain the supporting tools.</p>
<h2>Choosing a Docs-as-Code Approach</h2>
<p>Docs as Code doesn&#x27;t need to look the same for every team. The right approach depends on who contributes to the documentation, how much engineering support is available, and how much control the team needs over reviews and publishing.</p>
<table><thead><tr><th><strong>Team situation</strong></th><th><strong>Recommended approach</strong></th></tr></thead><tbody><tr><td>Engineering-led API team</td><td>Full Docs-as-Code workflow</td></tr><tr><td>Mixed product and support team</td><td>Hybrid Git and visual editor</td></tr><tr><td>Small team with limited engineering capacity</td><td>Managed documentation platform</td></tr><tr><td>Highly regulated environment</td><td>Git workflow with mandatory review and audit controls</td></tr></tbody></table>
<p>The goal is not to adopt the most technical workflow possible. Choose an approach that gives contributors enough control to maintain accurate documentation without adding unnecessary friction to the work.</p>
<h2>How to Apply Best Practices</h2>
<p>Start with the parts of the Docs-as-Code workflow that address problems your team already has. You don&#x27;t need to introduce every tool or automate every step at once.</p>
<ol>
<li><strong>Update documentation with product changes:</strong> When a feature, API, or configuration changes, include the related documentation work in the development workflow. This makes it less likely that documentation is forgotten after the product change is merged.</li>
<li><strong>Review documentation changes with the same as code changes:</strong> Use pull requests to make changes visible to reviewers and give them a place to check technical accuracy, clarity, and consistency before the documentation is published. <a href="https://reclear.io/blog/developer-documentation-that-gets-used">Good documentation also needs to be clear and useful to the developer reading it</a>.</li>
<li><strong>Automate checks that machines can handle:</strong> Use CI to check for broken links, invalid Markdown, build failures, and other repeatable problems. Keep human review for questions that require context and judgment.</li>
<li><strong>Define documentation ownership:</strong> Make it clear who is responsible for reviewing and updating documentation when the product changes. A version-controlled workflow makes changes easier to track, but it doesn&#x27;t decide who needs to make them.</li>
<li><strong>Keep the workflow proportional to the change:</strong> A small typo doesn&#x27;t need the same review process as a major API change. Use lightweight checks for routine updates and stronger review when the documentation affects important product behavior.</li>
<li><strong>Keep the workflow easy enough to use:</strong> If contributors need to navigate unnecessary tools, approvals, or complex automation to make a simple documentation change, they may avoid updating the content. The workflow should support maintenance rather than become another barrier to it.</li>
</ol>
<h2>When Does It Break Down</h2>
<p>Docs as Code works best when the team can use the workflow without adding unnecessary friction. If contributors aren&#x27;t comfortable with Git, Markdown, or pull requests, the process can become a barrier to making documentation changes instead of making them easier.</p>
<p>It can also break down when documentation ownership is unclear. Version control makes changes easier to track, but it doesn&#x27;t decide who should update the documentation when a product changes. Teams still need clear ownership so documentation work doesn&#x27;t get left between development and documentation teams.</p>
<p>Automation has limits as well. A documentation build can pass every automated check while the content is still inaccurate, incomplete, or difficult to understand. Automated checks can catch mechanical problems, but human reviewers still need to verify that the documentation reflects the product and makes sense to the reader.</p>
<p>Docs as Code works when the development practices add useful structure without making documentation harder to maintain.</p>
<h2>Need help building a Docs-as-Code workflow?</h2>
<p>Reclear helps developer-focused companies design documentation systems, improve technical content, and create workflows that keep docs aligned with product changes. <a href="https://reclear.io/book-a-call">Book a call with Reclear</a> to discuss your documentation challenges and see how we can help your team maintain accurate, up-to-date content.</p>]]></content:encoded>
      <pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Abdul Talha)</author>
      <category>Documentation</category>
      <category>DX</category>
      <category>TechnicalWriting</category>
      <enclosure url="https://reclear.io/blog/docs-as-code-benefits-challenges-and-best-practices/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Mintlify vs. Docusaurus vs. GitBook: Detailed Comparison (2026)</title>
      <link>https://reclear.io/blog/mintlify-docusaurus-vs-gitbook</link>
      <guid isPermaLink="true">https://reclear.io/blog/mintlify-docusaurus-vs-gitbook</guid>
      <description>Compare Mintlify, Docusaurus, and GitBook across pricing, setup, Git workflows, customization, API docs, AI features, hosting, and maintenance.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/mintlify-docusaurus-vs-gitbook/cover/cover.webp" alt="Mintlify vs. Docusaurus vs. GitBook: Detailed Comparison (2026)" /></p><p>Mintlify, Docusaurus, and GitBook are the three most popular documentation tools for <a href="https://reclear.io/blog/developer-documentation-that-gets-used">developer documentation</a>. Choosing between these tools comes down to how much control you want, how your team writes and publishes docs, and how much infrastructure you want to manage.</p>
<p>This guide compares the three across setup, writing workflow, customization, API documentation, versioning, search, AI features, analytics, SEO, hosting, maintenance, and pricing.</p>
<aside><ul>
<li><strong>Mintlify</strong> suits developer teams that want a managed docs platform with Git-based workflows and built-in publishing.</li>
<li><strong>Docusaurus</strong> gives you the most control, but your team handles deployment and maintenance.</li>
<li><strong>GitBook</strong> is a strong fit for teams that want easier collaboration between technical and nontechnical contributors.</li>
<li><strong>API documentation</strong> is more nuanced. Mintlify and GitBook offer dedicated features, while Docusaurus usually needs extra tooling.</li>
<li><strong>There is no single winner</strong>. Choose based on workflow, control, maintenance, and team structure.</li>
</ul></aside>
<h2>Comparison at a glance</h2>
<table><thead><tr><th>Category</th><th>Mintlify</th><th>Docusaurus</th><th>GitBook</th></tr></thead><tbody><tr><td>Pricing model</td><td>Free Starter; paid Pro and Enterprise plans</td><td>Free and open source; you pay for hosting and any third-party services</td><td>Free plan; paid site plans start at $65/month</td></tr><tr><td>Best for</td><td>Developer teams that want managed docs with Git workflows</td><td>Engineering teams that want full control and customization</td><td>Teams that want managed docs with strong browser-based collaboration</td></tr><tr><td>Time to first deploy</td><td>Minutes</td><td>Depends on setup and hosting</td><td>Minutes</td></tr><tr><td>Hosting</td><td>Managed by Mintlify</td><td>You choose and manage hosting</td><td>Managed by GitBook</td></tr><tr><td>Setup difficulty</td><td>Low</td><td>Medium to high</td><td>Low</td></tr><tr><td>Git workflow</td><td>Built in</td><td>Native Git workflow</td><td>GitHub and GitLab sync</td></tr><tr><td>Customization</td><td>High</td><td>Very high</td><td>Moderate to high</td></tr><tr><td>API docs</td><td>Built-in API playground and OpenAPI support</td><td>Usually requires plugins or custom tooling</td><td>Built-in OpenAPI and interactive API docs</td></tr><tr><td>Versioning</td><td>Built in</td><td>Built in through the versioning CLI</td><td>Supported through site variants</td></tr><tr><td>Search</td><td>Built in</td><td>Algolia or other search integrations</td><td>Built in</td></tr><tr><td>AI features</td><td>AI assistant, agent features, MCP</td><td>Available through integrations such as Algolia Ask AI</td><td>AI search, AI Assistant, MCP, LLM optimizations</td></tr><tr><td>Analytics</td><td>Built-in insights</td><td>Requires third-party analytics</td><td>Built-in site insights</td></tr><tr><td>Maintenance</td><td>Low</td><td>Higher</td><td>Low</td></tr></tbody></table>
<h2>What is Mintlify, Docusaurus, and GitBook?</h2>
<p>Mintlify, Docusaurus, and GitBook can all power developer documentation, but they take different approaches to how teams build, publish, and maintain it.</p>
<h3>Mintlify: Managed docs for developer teams</h3>
<img src="https://reclear.io/blog/mintlify-docusaurus-vs-gitbook/diagram/mintlify-homepage.webp" alt="Mintlify homepage showing its developer documentation platform" width="1600" height="900"/>
<p><a href="https://www.mintlify.com/docs">Mintlify</a> is a managed documentation platform built around a Git and MDX workflow. Developers can work locally and submit changes through Git, while other contributors can make changes through its web editor. Mintlify handles the documentation site&#x27;s build, deployment, and hosting.</p>
<p><strong>Best for:</strong> Developer-focused teams that want docs-as-code without maintaining the documentation infrastructure.</p>
<p><strong>Pros:</strong></p>
<ul>
<li>Supports Git-based and browser editing.</li>
<li>Handles hosting and deployment.</li>
<li>Supports OpenAPI-generated API documentation and interactive API features.</li>
<li>Includes built-in search and AI features.</li>
</ul>
<p><strong>Cons:</strong></p>
<ul>
<li>Offers less infrastructure control than a self-hosted solution.</li>
<li>Advanced features and larger teams require paid plans.</li>
</ul>
<h3>Docusaurus: Open source with more control</h3>
<img src="https://reclear.io/blog/mintlify-docusaurus-vs-gitbook/diagram/docusaurus-homepage.webp" alt="Docusaurus homepage showing its open-source documentation website builder" width="1600" height="900"/>
<p><a href="https://docusaurus.io/docs">Docusaurus</a> is an open-source static site generator maintained by Meta. Documentation is written in Markdown or MDX and compiled into a static website. Unlike Mintlify and GitBook, Docusaurus does not provide a managed documentation service. Your team chooses the hosting provider and controls the deployment.</p>
<p><strong>Best for:</strong> Engineering teams that want more control over their documentation site and are comfortable managing the underlying setup.</p>
<p><strong>Pros:</strong></p>
<ul>
<li>Free and open source.</li>
<li>Highly customizable with React.</li>
<li>Supports documentation versioning.</li>
<li>Gives you control over hosting and deployment.</li>
<li>Has a large plugin and integration ecosystem.</li>
</ul>
<p><strong>Cons:</strong></p>
<ul>
<li>Requires more setup and ongoing maintenance.</li>
<li>Search, analytics, and some documentation features may require third-party services or additional configuration.</li>
<li>Less accessible to nontechnical contributors without a separate editorial workflow.</li>
</ul>
<h3>GitBook: Managed docs for collaborative teams</h3>
<img src="https://reclear.io/blog/mintlify-docusaurus-vs-gitbook/diagram/gitbook-homepage.webp" alt="GitBook homepage showing its collaborative documentation platform" width="1600" height="900"/>
<p><a href="https://gitbook.com/docs">GitBook</a> is a managed documentation platform that combines browser-based editing with Git workflows. Teams can create and edit documentation in GitBook or use Git Sync to keep content synchronized with GitHub or GitLab. GitBook handles publishing and hosting.</p>
<p><strong>Best for:</strong> Teams that want developers and nontechnical contributors to work on the same documentation.</p>
<p><strong>Pros:</strong></p>
<ul>
<li>Visual editor for browser-based writing.</li>
<li>GitHub and GitLab synchronization.</li>
<li>Managed hosting and publishing.</li>
<li>Built-in search and AI capabilities.</li>
<li>Supports OpenAPI-based API documentation.</li>
</ul>
<p><strong>Cons:</strong></p>
<ul>
<li>Less control over the underlying infrastructure than Docusaurus.</li>
<li>Some advanced publishing and collaboration features require paid plans.</li>
</ul>
<p><strong>Bottom line:</strong> Mintlify prioritizes the developer workflow, Docusaurus gives you the most control, and GitBook makes collaborative authoring easier. The rest of this comparison looks at where those differences matter in practice.</p>
<h2>Mintlify vs. Docusaurus vs. GitBook: Feature comparison</h2>
<p>The biggest differences between these tools show up in how teams build, maintain, and use their documentation. Let&#x27;s compare the six areas that matter most.</p>
<ol>
<li>
<p><strong>Setup and deployment</strong></p>
<p>Mintlify and GitBook handle the infrastructure for you. Docusaurus gives you control over it.</p>
<p>With Mintlify, you can start from a template or connect an existing repository. Mintlify builds and hosts the documentation site, so your team doesn&#x27;t need to set up a separate deployment pipeline.</p>
<p>GitBook follows a similar managed model. You create a site, add content, and publish it through GitBook. You can also connect a GitHub or GitLab repository through <a href="https://gitbook.com/docs/docs-as-code/git-sync">Git Sync</a>.</p>
<p>Docusaurus works differently. Running a production build generates static files in the <code>build</code> directory. Your team then deploys those files to a provider such as Vercel, Netlify, GitHub Pages, or your own infrastructure. Docusaurus describes building the static files as its only deployment responsibility.</p>
<p><strong>Winner:</strong> Mintlify and GitBook for ease of setup. Docusaurus for infrastructure control.</p>
</li>
<li>
<p><strong>Writing and collaboration</strong></p>
<p>All three support Markdown-based documentation, but the authoring experience differs.</p>
<p>Mintlify stores content as MDX and supports a Git-based workflow. Developers can work locally and use branches and pull requests, while contributors can also edit through the web editor.</p>
<p>GitBook puts more emphasis on browser-based collaboration. Its editor lets contributors work without Git, while Git Sync connects the same documentation to GitHub or GitLab.</p>
<p>Docusaurus takes a traditional <a href="https://reclear.io/blog/developer-documentation-is-a-product-feature-not-an-afterthought">docs-as-code workflow</a>. Documentation lives in Markdown or MDX files alongside the project, making Git the natural review and collaboration layer.</p>
<p>That makes the choice fairly straightforward:</p>
<ul>
<li><strong>Engineering-led documentation:</strong> Docusaurus or Mintlify.</li>
<li><strong>Mixed technical and nontechnical contributors:</strong> GitBook or Mintlify.</li>
<li><strong>Strict docs-as-code workflow:</strong> Docusaurus gives you the most direct Git-based setup.</li>
</ul>
</li>
<li>
<p><strong>Customization</strong></p>
<p>This is where Docusaurus stands out.</p>
<p>Docusaurus is built with React and lets you customize themes, layouts, components, routing, plugins, CSS, and much of the frontend itself. You can also replace parts of the default theme through a process Docusaurus calls swizzling. <a href="https://docusaurus.io/docs/styling-layout">See the Docusaurus customization documentation</a>.</p>
<p>The trade-off is engineering work. More control means more code to build and maintain.</p>
<p>Mintlify provides customization through configuration, MDX components, custom React components, CSS, and other site settings without requiring you to own the entire frontend.</p>
<p>GitBook gives you control over branding, navigation, layouts, custom domains, and site appearance, but it isn&#x27;t designed to give you the same frontend control as Docusaurus.</p>
<p><strong>Winner:</strong> Docusaurus. Choose it when deep customization matters enough to justify the extra engineering work.</p>
</li>
<li>
<p><strong>API documentation</strong></p>
<p>For API-heavy products, this is one of the biggest differences.</p>
<p>Mintlify can generate API reference pages from OpenAPI specifications and includes an interactive API playground. Its API documentation stays within the same Git-based workflow as the rest of the docs. <a href="https://www.mintlify.com/docs/api-playground/overview">See Mintlify&#x27;s API documentation guide</a>.</p>
<p>GitBook also supports OpenAPI documents. Teams can use an OpenAPI specification to create interactive API references rather than maintaining each endpoint manually.</p>
<p>Docusaurus does not provide the same native OpenAPI workflow. Teams commonly add API documentation through plugins or other tools. This gives you flexibility, but also adds another part of the documentation stack to configure and maintain.</p>
<p><strong>Winner:</strong> Mintlify and GitBook. Both provide API documentation features without requiring you to assemble the tooling yourself.</p>
</li>
<li>
<p><strong>Versioning</strong></p>
<p>All three can support multiple versions of documentation, but they approach the problem differently.</p>
<p>Docusaurus has native documentation versioning. Its CLI can snapshot the current documentation into a versioned directory, letting teams maintain older releases alongside current docs. Docusaurus itself warns against versioning unnecessarily because each version adds files, build time, and maintenance.</p>
<p>That warning is worth paying attention to. Versioning isn&#x27;t just a switch you turn on. If your product has five supported versions, your documentation team may eventually need to maintain five sets of related content.</p>
<p>Mintlify provides versioned documentation within its managed platform, while <a href="https://gitbook.com/docs/manage-your-site/site-structure/variants">GitBook can use variants</a> to publish different versions of a documentation site.</p>
<p><strong>The practical difference is ownership:</strong> Docusaurus exposes more of the versioning structure to you, while Mintlify and GitBook manage more of the publishing layer.</p>
</li>
<li>
<p><strong>Search and AI features</strong></p>
<p>This category has changed significantly as documentation platforms adapt to AI agents and AI-assisted search.</p>
<p>Mintlify includes search and AI features as part of the platform. Its current tooling also extends documentation to AI agents through features such as <code>llms.txt</code> and Model Context Protocol (MCP).</p>
<p>GitBook has also moved beyond keyword search. Its current platform includes AI-powered documentation search and tools for exposing documentation to AI systems, including MCP and <code>llms.txt</code>.</p>
<p>Docusaurus does not ship an equivalent managed AI layer. Search is typically added through services such as Algolia DocSearch, and teams can add other capabilities through plugins or their own implementation.</p>
<p>That doesn&#x27;t necessarily make Docusaurus worse. It reflects the larger difference between the products: Mintlify and GitBook give you more documentation features out of the box, while Docusaurus gives you the foundation to build the experience yourself.</p>
</li>
</ol>
<h2>Mintlify vs. Docusaurus vs. GitBook pricing</h2>
<p>The three tools use different pricing models. Mintlify and GitBook charge for managed platforms, while Docusaurus is free and open source but leaves hosting and maintenance to your team.</p>
<h3>Mintlify pricing</h3>
<p><a href="https://www.mintlify.com/pricing">Mintlify has three plans</a>:</p>
<ul>
<li><strong>Starter:</strong> Free. Includes five editor seats, Git sync, a custom domain, web editor, search, API playground, authentication, and an MCP server.</li>
<li><strong>Pro: $450 per month billed annually or $540 per month billed monthly</strong>. Includes unlimited editor seats and adds preview deployments, Agent, Assistant, Automations, and admin APIs.</li>
<li><strong>Enterprise:</strong> Custom pricing. Adds SSO, SCIM, role-based access control (RBAC), advanced insights, a performance SLA, migration support, and enterprise security features.</li>
</ul>
<p>There is also a usage cost to consider on Pro. Mintlify includes <strong>10,000 credits per month</strong> for its AI features and charges <strong>$0.01 per credit</strong> for overages.</p>
<p>For a small team, the free Starter plan covers much of the core documentation workflow. The price jump comes when you need features such as preview deployments, AI agents, automations, or enterprise controls.</p>
<h3>Docusaurus pricing</h3>
<p>Docusaurus is <strong>free and open source</strong>. There are no per-user, per-site, or Docusaurus subscription fees.</p>
<p>The cost comes from running it.</p>
<p><a href="https://docusaurus.io/docs/deployment">Docusaurus builds your documentation into static files</a> and leaves hosting to you. You can deploy those files to GitHub Pages, Vercel, Netlify, Render, your own server, or another static hosting provider.</p>
<p>Some costs can stay at $0:</p>
<ul>
<li>GitHub Pages provides free build and deployment for public repositories.</li>
<li><a href="https://docsearch.algolia.com/docs/who-can-apply/">Algolia DocSearch is free</a> for eligible public developer documentation and technical blogs.</li>
<li>Some static hosting providers offer free tiers that may be enough for smaller documentation sites.</li>
</ul>
<p>But a production setup can introduce costs for hosting, private search, analytics, continuous integration and continuous deployment (CI/CD), monitoring, and other services.</p>
<p>The largest hidden cost is often <strong>engineering time</strong>. Your team owns the build configuration, dependencies, deployment pipeline, upgrades, integrations, and any custom React components.</p>
<h3>GitBook pricing</h3>
<p><a href="https://www.gitbook.com/pricing">GitBook charges per published site and per collaborating user</a>:</p>
<ul>
<li><strong>Free:</strong> $0, with one user.</li>
<li><strong>Premium: $65 per site per month</strong> when billed annually, plus <strong>$12 per user per month</strong>.</li>
<li><strong>Ultimate: $249 per site per month</strong> when billed annually, plus <strong>$12 per user per month</strong>.</li>
<li><strong>Enterprise:</strong> Custom pricing.</li>
</ul>
<p>Premium adds features such as custom domains, AI search, analytics, user feedback, redirects, and advanced branding. Ultimate adds GitBook&#x27;s AI Assistant and other advanced features.</p>
<p>For example, a five-person team running one Premium documentation site would cost:</p>
<p><code>$65 + (5 × $12) = $125 per month</code>, or <code>$1,500 per year</code> at the published annual-billing rate.</p>
<p>The same five-person team on Ultimate would pay:</p>
<p><code>$249 + (5 × $12) = $309 per month</code>, or <code>$3,708 per year</code>.</p>
<p>GitBook does not charge based on documentation traffic. Page views are unlimited across its plans.</p>
<p><strong>Total cost of ownership:</strong></p>
<p>The subscription price doesn&#x27;t tell the whole story.</p>
<table><thead><tr><th>Cost</th><th>Mintlify</th><th>Docusaurus</th><th>GitBook</th></tr></thead><tbody><tr><td>Platform</td><td>$0; Pro from $450/month when billed annually</td><td>$0</td><td>$0; Premium from $65/month when billed annually</td></tr><tr><td>Additional users</td><td>Included by plan</td><td>$0 platform fee</td><td>$12/user/month on paid plans</td></tr><tr><td>Hosting</td><td>Included</td><td>You provide it</td><td>Included</td></tr><tr><td>Search</td><td>Included</td><td>Separate; can be free</td><td>Included</td></tr><tr><td>Deployment</td><td>Managed</td><td>You manage it</td><td>Managed</td></tr><tr><td>Maintenance</td><td>Mostly managed</td><td>Your team</td><td>Mostly managed</td></tr><tr><td>AI usage costs</td><td>Credits and possible overages</td><td>Depends on tooling</td><td>Included features vary by plan</td></tr></tbody></table>
<p><strong>Bottom line:</strong> Compare the subscription price with the engineering time your team will spend running the documentation stack. For Docusaurus in particular, &quot;$0&quot; describes the software license, not necessarily the cost of ownership.</p>
<h2>When should you choose Mintlify?</h2>
<p>Choose <a href="https://www.mintlify.com/docs">Mintlify</a> if you want a developer-focused documentation platform without owning the infrastructure.</p>
<p>Mintlify is a good fit if:</p>
<ul>
<li><strong>Your developers already work in Git.</strong> Documentation can live in the repository and follow the same branch, pull request, and review workflow as code.</li>
<li><strong>You want managed docs without giving up docs-as-code.</strong> Mintlify handles hosting and deployment while your team works with MDX and Git.</li>
<li><strong><a href="https://reclear.io/blog/what-makes-great-api-documentation">API documentation</a> is a core part of your docs.</strong> Mintlify supports OpenAPI-generated references and an interactive API playground.</li>
<li><strong>You want AI features built into the documentation platform.</strong> Mintlify provides AI search and assistant features, <code>llms.txt</code>, MCP support, and tools for making documentation available to AI agents.</li>
<li><strong>You don&#x27;t want engineers maintaining the docs stack.</strong> You give up some infrastructure control, but you also avoid managing builds, hosting, search, and much of the deployment pipeline.</li>
</ul>
<p><strong>Skip Mintlify if:</strong> You need full control over the frontend or want to self-host the entire documentation stack.</p>
<p><strong>Best fit:</strong> Developer tools, APIs, SDKs, and technical products with engineering-led documentation.</p>
<h2>When should you choose Docusaurus?</h2>
<p>Choose <a href="https://docusaurus.io/docs">Docusaurus</a> if control matters more than convenience.</p>
<p>Docusaurus is a good fit if:</p>
<ul>
<li><strong>Your team is comfortable with React.</strong> You can build custom components, layouts, themes, and documentation experiences instead of staying within a managed platform&#x27;s boundaries.</li>
<li><strong>You want to control hosting and deployment.</strong> Docusaurus generates static files that you can deploy through your existing infrastructure.</li>
<li><strong>Your docs need heavy customization.</strong> This is useful when documentation needs to share components, branding, or functionality with the rest of your developer experience.</li>
<li><strong>You already have engineering resources for the docs stack.</strong> Maintaining dependencies, CI/CD, hosting, search, plugins, and upgrades is less of a problem when someone owns them.</li>
<li><strong>You want an open-source foundation.</strong> Docusaurus itself has no platform subscription or per-user fee.</li>
</ul>
<p><strong>Skip Docusaurus if:</strong> You want a documentation platform your team can publish and maintain with little engineering involvement.</p>
<p><strong>Best fit:</strong> Engineering-led teams that value customization, infrastructure ownership, and extensibility over a managed experience.</p>
<h2>When should you choose GitBook?</h2>
<p>Choose <a href="https://gitbook.com/docs">GitBook</a> if documentation is shared across engineering, product, support, and other teams.</p>
<p>GitBook is a good fit if:</p>
<ul>
<li><strong>Not everyone writing documentation uses Git.</strong> Contributors can work in GitBook&#x27;s visual editor without managing repositories or local development environments.</li>
<li><strong>Developers still need a Git workflow.</strong> Git Sync connects documentation with GitHub or GitLab, so technical contributors don&#x27;t have to abandon their existing workflow.</li>
<li><strong>Collaboration matters more than frontend control.</strong> GitBook handles publishing and hosting while giving teams tools for editing, reviewing, and managing content.</li>
<li><strong>You need public and private documentation.</strong> GitBook supports published documentation as well as authenticated access for restricted content.</li>
<li><strong>You want API docs without building the tooling yourself.</strong> GitBook can generate interactive API documentation from OpenAPI specifications.</li>
</ul>
<p><strong>Skip GitBook if:</strong> Deep frontend customization or complete infrastructure ownership is a requirement.</p>
<p><strong>Best fit:</strong> Cross-functional teams that need developers and nontechnical contributors to maintain documentation together.</p>
<p>Here is a quick decision. If you&#x27;re still deciding, reduce it to one question:</p>
<ul>
<li><strong>Want a managed platform built around developer workflows?</strong> Choose Mintlify.</li>
<li><strong>Want maximum control and don&#x27;t mind maintaining the stack?</strong> Choose Docusaurus.</li>
<li><strong>Want developers and nontechnical teams contributing together?</strong> Choose GitBook.</li>
</ul>
<h2>Final Take</h2>
<p>Mintlify offers a managed, developer-focused workflow, Docusaurus gives engineering teams more control, and GitBook makes it easier for technical and nontechnical teams to work on documentation together. The right choice depends on how your team works and how much of the documentation stack you want to manage.</p>
<h2>Need help with your developer documentation?</h2>
<p>Choosing the right platform is only part of the job. <a href="https://reclear.io">Reclear</a> helps developer-first teams plan, build, and improve documentation for APIs, SDKs, and technical products across Mintlify, Docusaurus, GitBook, and other documentation stacks.</p>
<p>We also offer a free documentation audit to identify gaps in your structure, content, developer experience, and tooling.</p>
<p><a href="https://reclear.io/book-a-call">Book a call with Reclear</a></p>]]></content:encoded>
      <pubDate>Tue, 18 Aug 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Olusegun Durojaye)</author>
      <category>Documentation</category>
      <category>DX</category>
      <category>TechnicalWriting</category>
      <enclosure url="https://reclear.io/blog/mintlify-docusaurus-vs-gitbook/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>How to Fix the &quot;CrashLoopBackOff&quot; Error in Kubernetes</title>
      <link>https://reclear.io/blog/how-to-fix-crashloopbackoff-in-kubernetes</link>
      <guid isPermaLink="true">https://reclear.io/blog/how-to-fix-crashloopbackoff-in-kubernetes</guid>
      <description>Learn how to diagnose CrashLoopBackOff with kubectl describe and logs --previous, then fix app crashes, OOMKills, and failing liveness probes.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/how-to-fix-crashloopbackoff-in-kubernetes/cover/cover.webp" alt="How to Fix the &quot;CrashLoopBackOff&quot; Error in Kubernetes" /></p><aside><ul>
<li><code>CrashLoopBackOff</code> means the container keeps starting and exiting. The status is the kubelet&#x27;s backoff timer, not the root cause.</li>
<li>Diagnose in this order: <code>kubectl get pods</code> → <code>kubectl describe pod</code> (Events + Last State exit code) → <code>kubectl logs --previous</code>.</li>
<li>Map the exit code: <code>1</code>/<code>2</code> app or config error, <code>137</code> often OOMKilled, <code>143</code> often SIGTERM from a probe or graceful kill, <code>127</code> missing binary or bad command.</li>
<li>Do not delete the pod to &quot;fix&quot; it. Fix the crash reason, then verify restarts stop climbing and the pod stays <code>Running</code> / <code>1/1</code>.</li>
</ul></aside>
<p>You deploy a new image, <code>kubectl get pods</code> shows <code>CrashLoopBackOff</code>, and the restart count climbs every few minutes. Deleting the pod feels productive, but the replacement comes back with the same status. The dashboard says the rollout failed. It does not say whether the process panicked, the memory limit is too low, or a liveness probe is killing a slow starter.</p>
<p>In this article, you will reproduce a crash loop in a sandbox namespace, read Last State and previous logs, fix the three causes you hit most often (app/config exit, OOMKilled, bad liveness probe), and verify the pod stays healthy.</p>
<aside><p><strong>What you need:</strong> <code>kubectl</code> 1.28+, access to a cluster (kind, minikube, EKS, GKE, or AKS), and permission to create namespaces and Deployments in a non-production sandbox. The walkthrough uses namespace <code>demo-app</code>.</p></aside>
<h2>Why pods enter CrashLoopBackOff</h2>
<p>Kubernetes starts the container. The process exits with a non-zero code (or is killed). The kubelet restarts it according to <code>restartPolicy</code> (usually <code>Always</code> for Deployments). After repeated failures, the kubelet waits longer between attempts. That waiting state is <code>CrashLoopBackOff</code>.</p>
<p>The <a href="https://kubernetes.io/docs/tasks/debug/debug-application/debug-pods/">Kubernetes debugging docs</a> treat this as a symptom. Your job is to find why the last container terminated.</p>
<table><thead><tr><th>Status</th><th>What it means</th></tr></thead><tbody><tr><td><code>Error</code> / <code>CrashLoopBackOff</code></td><td>Container exited; kubelet is restarting with backoff</td></tr><tr><td><code>OOMKilled</code> (in Last State)</td><td>Container exceeded its memory limit</td></tr><tr><td><code>CreateContainerConfigError</code></td><td>Pod never started (missing Secret/ConfigMap reference). Not a crash loop</td></tr><tr><td><code>ImagePullBackOff</code></td><td>Image never arrived. Different failure mode</td></tr></tbody></table>
<p>Use this exit-code map when you read <code>Last State</code> from <code>kubectl describe pod</code>:</p>
<table><thead><tr><th>Exit code</th><th>Typical meaning</th><th>Where to look next</th></tr></thead><tbody><tr><td><code>0</code></td><td>Clean exit (unexpected for a long-running server)</td><td>Command completed; check if the process is a one-shot job in a Deployment</td></tr><tr><td><code>1</code> / <code>2</code></td><td>Application or config error</td><td><code>kubectl logs --previous</code></td></tr><tr><td><code>127</code></td><td>Command or binary not found</td><td><code>command</code> / <code>args</code> vs image contents</td></tr><tr><td><code>137</code></td><td>SIGKILL (often OOMKilled)</td><td>Last State reason, memory limits</td></tr><tr><td><code>143</code></td><td>SIGTERM (often probe or graceful shutdown)</td><td>Events for <code>Liveness probe failed</code></td></tr></tbody></table>
<aside><p>Deleting the pod resets the backoff clock. It does not change the crash reason. Fix the root cause first.</p></aside>
<h2>How to diagnose with describe and previous logs</h2>
<p>Start with status, then Last State, then previous logs. Guessing from the Deployment YAML alone wastes time.</p>
<p>Create a namespace and a Deployment that exits immediately so you can practice the workflow:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: demo-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: busybox:1.36
          command: [&quot;sh&quot;, &quot;-c&quot;, &quot;echo &#x27;FATAL: missing DATABASE_URL&#x27; &gt;&amp;2; exit 1&quot;]
</code></pre>
<p>Apply it and watch the status flip to <code>CrashLoopBackOff</code>:</p>
<pre><code class="language-bash">kubectl create namespace demo-app
kubectl apply -f crash-deployment.yaml
kubectl get pods -n demo-app -w
</code></pre>
<p>You should see <code>STATUS</code> move through <code>Error</code> into <code>CrashLoopBackOff</code> while <code>RESTARTS</code> climbs.</p>
<p>Describe the pod and read two sections: <strong>Last State</strong> (reason + exit code) and <strong>Events</strong>:</p>
<pre><code class="language-bash">kubectl describe pod -n demo-app -l app=api
</code></pre>
<p>Expect output shaped like this:</p>
<pre><code class="language-text">Last State:     Terminated
  Reason:       Error
  Exit Code:    1
  Started:      Sat, 11 Jul 2026 10:01:02 +0000
  Finished:     Sat, 11 Jul 2026 10:01:02 +0000
Events:
  Type     Reason   Age                From     Message
  ----     ------   ----               ----     -------
  Normal   Created  12s (x3 over 40s)  kubelet  Created container: api
  Normal   Started  12s (x3 over 40s)  kubelet  Started container api
  Warning  BackOff  5s (x4 over 39s)   kubelet  Back-off restarting failed container api in pod api-...
</code></pre>
<p>Pull logs from the container that already died. Current logs are often empty while the new instance is still starting:</p>
<pre><code class="language-bash">kubectl logs -n demo-app -l app=api --previous
</code></pre>
<p>You should see:</p>
<pre><code class="language-text">FATAL: missing DATABASE_URL
</code></pre>
<p>That string is the fix target. Everything below maps an exit code or Event pattern to a concrete change.</p>
<h2>How to fix application and config crashes</h2>
<p>Exit codes <code>1</code> and <code>2</code> with a clear log line usually mean the process refused to start: missing env, bad config file, failed migration, or an unhandled exception.</p>
<p>Confirm referenced ConfigMaps and Secrets exist in the <strong>same namespace</strong>, and that key names match what the app reads. A typo in <code>secretKeyRef.key</code> often surfaces as a boot failure in logs, or as <code>CreateContainerConfigError</code> before the container ever starts. For how ConfigMaps and Secrets differ, see <a href="https://reclear.io/blog/kubernetes-configmap-vs-secret-explained">Kubernetes ConfigMap vs Secret Explained</a>.</p>
<p>Fix the crash Deployment by exporting the required variable and staying up:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: demo-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: busybox:1.36
          command:
            - sh
            - -c
            - |
              if [ -z &quot;$DATABASE_URL&quot; ]; then
                echo &quot;FATAL: missing DATABASE_URL&quot; &gt;&amp;2
                exit 1
              fi
              echo &quot;api listening&quot;
              sleep 3600
          env:
            - name: DATABASE_URL
              value: postgres://app:secret@db:5432/app
</code></pre>
<p>Apply and wait for the rollout:</p>
<pre><code class="language-bash">kubectl apply -f fixed-app-deployment.yaml
kubectl rollout status deployment/api -n demo-app
kubectl get pods -n demo-app
</code></pre>
<p><code>STATUS</code> should be <code>Running</code> and <code>RESTARTS</code> should stop increasing.</p>
<p>If logs show a stack trace from your language runtime, reproduce with the same image and env locally (<code>docker run --rm -e ... image:tag</code>) before changing cluster YAML again. Wrong <code>command</code> or <code>args</code> that point at a missing binary usually show exit <code>127</code> instead.</p>
<h2>How to fix OOMKilled</h2>
<p>When Last State shows <code>Reason: OOMKilled</code> or exit code <code>137</code>, the kubelet (via the runtime) killed the container for exceeding its memory limit. Raising the limit without measuring usage only hides a leak until the next traffic spike.</p>
<p>Reproduce with a tiny limit so you can see the pattern safely:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: demo-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: busybox:1.36
          command:
            - sh
            - -c
            - |
              # Allocate until the limit kills the process
              dd if=/dev/zero of=/dev/shm/blob bs=1M count=64
              sleep 3600
          resources:
            requests:
              memory: &quot;16Mi&quot;
            limits:
              memory: &quot;32Mi&quot;
</code></pre>
<p>After apply, describe the pod. You want <code>Last State: Terminated</code> with <code>Reason: OOMKilled</code>.</p>
<p>Raise the limit to a value the workload can actually use, and keep a request so the scheduler places it correctly:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: demo-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: busybox:1.36
          command: [&quot;sh&quot;, &quot;-c&quot;, &quot;sleep 3600&quot;]
          resources:
            requests:
              memory: &quot;64Mi&quot;
            limits:
              memory: &quot;256Mi&quot;
</code></pre>
<p>Apply, then confirm Last State is no longer <code>OOMKilled</code> and the pod stays <code>Running</code>.</p>
<p>In production, pair the limit change with metrics (<code>container_memory_working_set_bytes</code>) so you know whether you fixed sizing or papered over a leak.</p>
<h2>How to fix failing liveness probes</h2>
<p>A healthy process can still CrashLoop if the liveness probe fails. The kubelet kills the container, Events show <code>Liveness probe failed</code>, and the exit code is often <code>137</code> or <code>143</code> depending on whether SIGKILL or SIGTERM won.</p>
<p>This Deployment listens too late for an aggressive probe:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: demo-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: busybox:1.36
          command:
            - sh
            - -c
            - |
              sleep 20
              nc -lk -p 8080 -e /bin/true
          ports:
            - containerPort: 8080
          livenessProbe:
            tcpSocket:
              port: 8080
            initialDelaySeconds: 3
            periodSeconds: 5
            failureThreshold: 1
</code></pre>
<p>Describe the pod after it loops. Events should mention <code>Liveness probe failed</code>. Stretching <code>initialDelaySeconds</code> forever is the wrong long-term fix for slow apps.</p>
<p>Add a <code>startupProbe</code> so liveness (and readiness) stay off until startup succeeds, then keep a sane liveness check:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: demo-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: nginx:1.25-alpine
          ports:
            - containerPort: 80
          startupProbe:
            httpGet:
              path: /
              port: 80
            failureThreshold: 30
            periodSeconds: 2
          livenessProbe:
            httpGet:
              path: /
              port: 80
            periodSeconds: 10
            failureThreshold: 3
</code></pre>
<p>Apply and confirm Events no longer show repeated liveness kills.</p>
<p>Two probe rules prevent most of these incidents:</p>
<ol>
<li>Do not probe external dependencies (databases, third-party APIs) in liveness. A brief dependency blip should not restart every replica.</li>
<li>Use <code>startupProbe</code> for anything that needs more than a few seconds to boot. That is what the <a href="https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/">configure liveness, readiness, and startup probes</a> docs recommend.</li>
</ol>
<h2>How to verify the pod is healthy</h2>
<p>A real fix shows up in three places:</p>
<ol>
<li><strong>Status:</strong> <code>kubectl get pods -n demo-app</code> shows <code>Running</code> and <code>READY</code> <code>1/1</code>.</li>
<li><strong>Restarts:</strong> The <code>RESTARTS</code> column stops climbing for several minutes.</li>
<li><strong>Events / logs:</strong> No new <code>BackOff</code>, <code>OOMKilled</code>, or <code>Liveness probe failed</code> lines; current logs show the process staying up.</li>
</ol>
<p>Useful checks:</p>
<pre><code class="language-bash">kubectl get pods -n demo-app
kubectl describe pod -n demo-app -l app=api | sed -n &#x27;/Containers:/,/Conditions:/p&#x27;
kubectl logs -n demo-app -l app=api --tail=50
kubectl rollout status deployment/api -n demo-app
</code></pre>
<p>If the crash started right after a deploy and you need traffic back while you debug, roll back first, then fix forward:</p>
<pre><code class="language-bash">kubectl rollout undo deployment/api -n demo-app
kubectl rollout status deployment/api -n demo-app
</code></pre>
<h2>When these fixes do not apply</h2>
<p><code>CrashLoopBackOff</code> always means the container exited, but not every exit is app code, OOM, or probes.</p>
<ul>
<li><strong>Init container failure:</strong> The app container never starts. Describe the pod and check init container Last State and logs (<code>kubectl logs POD -c INIT_NAME</code>).</li>
<li><strong>Missing volume permissions:</strong> A non-root container cannot write a mounted path. Fix <code>securityContext</code> (<code>runAsUser</code>, <code>fsGroup</code>) or volume permissions rather than raising memory.</li>
<li><strong>Dependency not ready at boot:</strong> The app exits if the database is down and has no retry. Fix the app startup policy or gate with a check that does not kill the process forever on first failure.</li>
<li><strong>Wrong image architecture:</strong> Pull succeeds, then the container dies with <code>exec format error</code>. Rebuild for the node architecture. If the image never arrives, you are in <a href="https://reclear.io/blog/how-to-fix-imagepullbackoff-error-in-kubernetes">ImagePullBackOff</a>, not CrashLoopBackOff.</li>
<li><strong>Node pressure / eviction:</strong> The pod may be restarted for reasons outside your container. Check node conditions and cluster autoscaling events, not only container logs.</li>
</ul>
<p>For authoritative reference, see the Kubernetes docs on <a href="https://kubernetes.io/docs/tasks/debug/debug-application/debug-pods/">debugging pods</a> and <a href="https://cloud.google.com/kubernetes-engine/docs/troubleshooting/crashloopbackoff-events">GKE CrashLoopBackOff troubleshooting</a>.</p>
<h2>Frequently asked questions</h2>
<p><strong>Q: What does CrashLoopBackOff mean?</strong></p>
<p>It means the container keeps crashing and the kubelet is delaying restarts. Find the cause in Last State, Events, and <code>kubectl logs --previous</code>.</p>
<p><strong>Q: Should I delete the pod?</strong></p>
<p>No. Deletion only resets backoff. Fix the crash reason so the next start succeeds.</p>
<p><strong>Q: Why are current logs empty?</strong></p>
<p>The live container may have just started. Read <code>--previous</code> logs from the instance that exited.</p>
<p><strong>Q: CrashLoopBackOff vs ImagePullBackOff?</strong></p>
<p><code>ImagePullBackOff</code> means the image never pulled. <code>CrashLoopBackOff</code> means the image ran and the process exited. Diagnose pull failures with Events; diagnose crash loops with Last State and previous logs.</p>
<p><strong>Q: Exit 137 always means OOM?</strong></p>
<p>Usually it is SIGKILL, and OOMKilled is the common Kubernetes case. Confirm with <code>Reason: OOMKilled</code> in describe. Probe failures and stubborn processes can also end in <code>137</code> after SIGTERM times out.</p>]]></content:encoded>
      <pubDate>Sat, 11 Jul 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Ilyas Rufai)</author>
      <category>Tutorials</category>
      <category>Kubernetes</category>
      <category>DevOps</category>
      <category>Containers</category>
      <enclosure url="https://reclear.io/blog/how-to-fix-crashloopbackoff-in-kubernetes/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Kubernetes ConfigMap vs Secret Explained</title>
      <link>https://reclear.io/blog/kubernetes-configmap-vs-secret-explained</link>
      <guid isPermaLink="true">https://reclear.io/blog/kubernetes-configmap-vs-secret-explained</guid>
      <description>Learn when to use a Kubernetes ConfigMap vs Secret, why base64 is not encryption, and how to inject each into pods safely with env vars or volumes.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/kubernetes-configmap-vs-secret-explained/cover/cover.webp" alt="Kubernetes ConfigMap vs Secret Explained" /></p><aside><ul>
<li>Use a <strong>ConfigMap</strong> for non-sensitive settings (feature flags, URLs, log levels, config files). Use a <strong>Secret</strong> for credentials, tokens, TLS material, and registry auth.</li>
<li>Secret <code>data</code> values are <strong>base64-encoded, not encrypted</strong>. Anyone who can <code>kubectl get secret -o yaml</code> can decode them. Encryption at rest is optional and off by default in many clusters.</li>
<li>Both inject the same ways: individual env keys, <code>envFrom</code>, or volume mounts. Env vars freeze at container start; mounted files can refresh, but apps often still need a restart.</li>
<li>Prefer <code>stringData</code> when authoring Secrets in YAML, restrict Secret RBAC tightly, and move high-risk credentials to an external secret manager when etcd alone is not enough.</li>
</ul></aside>
<p>You ship a Deployment with <code>DB_PASSWORD</code> in a ConfigMap because &quot;it is just config,&quot; then discover the value in every <code>kubectl get configmap -o yaml</code>, every CI log that dumps manifests, and every RBAC role that grants broad ConfigMap read. Or you put the password in a Secret, see base64 in the YAML, and assume the cluster encrypted it. It did not.</p>
<p>In this article, you will get a clear ConfigMap vs Secret decision rule, working create-and-inject examples, and the security caveats that most &quot;explained&quot; posts bury: base64 is not encryption, env vars do not hot-reload, and Kubernetes Secrets are only as safe as your RBAC and etcd settings.</p>
<aside><p><strong>What you need:</strong> <code>kubectl</code> 1.28+, access to a cluster (kind, minikube, EKS, GKE, or AKS), and permission to create ConfigMaps, Secrets, and Deployments in a non-production namespace. The examples use namespace <code>demo-app</code>.</p></aside>
<h2>Why teams mix up ConfigMaps and Secrets</h2>
<p>Both objects store key/value data outside the container image and inject it into Pods. The API shapes look almost identical, so teams treat them as interchangeable storage with different names.</p>
<p>That is the failure mode. Kubernetes documents <a href="https://kubernetes.io/docs/concepts/configuration/configmap/">ConfigMaps</a> for non-confidential configuration and <a href="https://kubernetes.io/docs/concepts/configuration/secret/">Secrets</a> for confidential data. The platform also applies extra controls around Secrets (separate RBAC resource, tmpfs mounts on nodes, optional encryption providers). If you put a database password in a ConfigMap, you skip those controls on purpose.</p>
<p>Common mistakes look like this:</p>
<table><thead><tr><th>Mistake</th><th>What goes wrong</th></tr></thead><tbody><tr><td>Password or API key in a ConfigMap</td><td>Clear-text values in etcd and in any ConfigMap <code>get</code>/<code>list</code></td></tr><tr><td>Assuming Secret base64 means encryption</td><td><code>echo VALUE | base64 -d</code> recovers the plaintext instantly</td></tr><tr><td>Committing Secret YAML with <code>data</code> to Git</td><td>The repo becomes a credential dump (base64 is reversible)</td></tr><tr><td>Expecting env var updates without a restart</td><td>Pods keep stale values until the container restarts</td></tr><tr><td>Broad <code>get</code>/<code>list</code>/<code>watch</code> on Secrets</td><td>Any subject with those verbs can read every Secret in the namespace</td></tr></tbody></table>
<h2>ConfigMap vs Secret side by side</h2>
<p>Both are namespaced API objects. Both support <code>data</code> maps. Both can feed env vars or files. The differences that matter in production are intent, encoding, typing, and how the control plane treats the object.</p>
<table><thead><tr><th>Dimension</th><th>ConfigMap</th><th>Secret</th></tr></thead><tbody><tr><td>Intended data</td><td>Non-sensitive config</td><td>Sensitive credentials and keys</td></tr><tr><td>Examples</td><td><code>LOG_LEVEL</code>, feature flags, upstream URLs, nginx.conf</td><td>DB passwords, API tokens, TLS certs/keys, <code>dockerconfigjson</code></td></tr><tr><td>Primary fields</td><td><code>data</code> (UTF-8), optional <code>binaryData</code> (base64)</td><td><code>data</code> (base64), optional <code>stringData</code> (plain, converted by API)</td></tr><tr><td>Built-in types</td><td>None</td><td><code>Opaque</code>, <code>kubernetes.io/tls</code>, <code>kubernetes.io/dockerconfigjson</code>, service-account tokens, and others</td></tr><tr><td>Default etcd storage</td><td>Unencrypted unless API encryption is configured</td><td>Same: unencrypted unless encryption at rest is enabled</td></tr><tr><td>Node delivery</td><td>Sent when a Pod needs it</td><td>Same, with kubelet keeping Secret volume data in tmpfs</td></tr><tr><td>RBAC resource</td><td><code>configmaps</code></td><td><code>secrets</code> (grant separately and more tightly)</td></tr></tbody></table>
<p>Use the table as a filter, not a suggestion that Secrets are &quot;secure by default.&quot; They are the right <em>kind</em> of object for sensitive values. Hardening still depends on encryption at rest, least-privilege RBAC, and what you put in Git.</p>
<h2>How to create and inject a ConfigMap</h2>
<p>Start with non-sensitive settings so the Pod can change environment without rebuilding the image. Create the namespace once if it does not exist:</p>
<pre><code class="language-bash">kubectl create namespace demo-app
</code></pre>
<p>Create a ConfigMap from literals (useful for a few keys) or from a file (useful for full config documents):</p>
<pre><code class="language-bash">kubectl create configmap app-config \
  --namespace demo-app \
  --from-literal=APP_ENV=staging \
  --from-literal=LOG_LEVEL=info \
  --from-literal=FEATURE_CHECKOUT=true
</code></pre>
<p>The equivalent declarative manifest keeps the same keys under <code>data</code> in plain text:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
  namespace: demo-app
data:
  APP_ENV: staging
  LOG_LEVEL: info
  FEATURE_CHECKOUT: &quot;true&quot;
  nginx.conf: |
    worker_processes 1;
    events { worker_connections 1024; }
</code></pre>
<p>Inject individual keys as environment variables, or mount the whole object as files. The Deployment below does both so you can see the two patterns in one place:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: demo-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: busybox:1.36
          command: [&quot;sh&quot;, &quot;-c&quot;, &quot;env | grep -E &#x27;APP_ENV|LOG_LEVEL&#x27;; sleep 3600&quot;]
          env:
            - name: APP_ENV
              valueFrom:
                configMapKeyRef:
                  name: app-config
                  key: APP_ENV
            - name: LOG_LEVEL
              valueFrom:
                configMapKeyRef:
                  name: app-config
                  key: LOG_LEVEL
          volumeMounts:
            - name: nginx-config
              mountPath: /etc/nginx/nginx.conf
              subPath: nginx.conf
              readOnly: true
      volumes:
        - name: nginx-config
          configMap:
            name: app-config
            items:
              - key: nginx.conf
                path: nginx.conf
</code></pre>
<p>Apply and verify the env keys appear inside the container:</p>
<pre><code class="language-bash">kubectl apply -f app-config.yaml
kubectl apply -f app-with-configmap.yaml
kubectl exec -n demo-app deploy/api -- printenv APP_ENV LOG_LEVEL
</code></pre>
<p>You should see <code>staging</code> and <code>info</code>. For bulk injection of every key, use <code>envFrom</code> with <code>configMapRef</code> instead of listing each <code>configMapKeyRef</code>.</p>
<h2>How to create and inject a Secret</h2>
<p>Use a Secret for anything that would hurt you if it leaked in a ConfigMap dump or a CI artifact. Prefer <code>stringData</code> in manifests so you do not hand-encode values (the API server converts them to base64 <code>data</code> on write):</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Secret
metadata:
  name: db-secret
  namespace: demo-app
type: Opaque
stringData:
  DB_USERNAME: app
  DB_PASSWORD: change-me-now
</code></pre>
<p>Or create it imperatively without writing the password into a tracked file:</p>
<pre><code class="language-bash">kubectl create secret generic db-secret \
  --namespace demo-app \
  --from-literal=DB_USERNAME=app \
  --from-literal=DB_PASSWORD=&#x27;change-me-now&#x27;
</code></pre>
<p>Wire the Secret into the Pod the same way you wire a ConfigMap, but with <code>secretKeyRef</code> / <code>secretRef</code> / <code>secret</code> volume sources:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: demo-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
        - name: api
          image: busybox:1.36
          command: [&quot;sh&quot;, &quot;-c&quot;, &quot;sleep 3600&quot;]
          env:
            - name: DB_USERNAME
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: DB_USERNAME
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: DB_PASSWORD
          volumeMounts:
            - name: db-creds
              mountPath: /var/run/secrets/db
              readOnly: true
      volumes:
        - name: db-creds
          secret:
            secretName: db-secret
</code></pre>
<p>Confirm the Secret exists and that the Pod can read the keys (do this only in a sandbox; avoid printing production secrets into shared terminals or CI logs):</p>
<pre><code class="language-bash">kubectl apply -f db-secret.yaml
kubectl apply -f app-with-secret.yaml
kubectl get secret db-secret -n demo-app -o jsonpath=&#x27;{.data.DB_USERNAME}&#x27; | base64 -d; echo
kubectl exec -n demo-app deploy/api -- printenv DB_USERNAME
</code></pre>
<p>Typed Secrets matter for platform features. A <code>kubernetes.io/dockerconfigjson</code> Secret is what you attach via <code>imagePullSecrets</code> when private registry pulls fail with <code>unauthorized</code>. That path is covered in detail in <a href="https://reclear.io/blog/how-to-fix-imagepullbackoff-error-in-kubernetes">How to Fix the ImagePullBackOff Error in Kubernetes</a>. TLS Secrets (<code>kubernetes.io/tls</code>) feed Ingress and other controllers that expect <code>tls.crt</code> and <code>tls.key</code>.</p>
<aside><p>Mark ConfigMaps and Secrets as <code>immutable: true</code> when values should not change in place. Immutability blocks accidental updates and can reduce kubelet watches for workloads that pin a specific config generation.</p></aside>
<h2>Why base64 is not encryption</h2>
<p>This is the gap most ConfigMap vs Secret explainers underplay. When you <code>kubectl get secret db-secret -o yaml</code>, values under <code>data</code> look obfuscated. They are not.</p>
<p>Base64 is a transport encoding so binary-safe payloads can sit in JSON/YAML. Decoding requires no key:</p>
<pre><code class="language-bash">echo &#x27;Y2hhbmdlLW1lLW5vdw==&#x27; | base64 -d
# change-me-now
</code></pre>
<p>Official Kubernetes docs state that <a href="https://kubernetes.io/docs/concepts/configuration/secret/#information-security-for-secrets">Secrets are stored unencrypted in etcd by default</a>, and that anyone with API or etcd access can retrieve them. Encoding does not change that.</p>
<p>Treat these as the minimum hardening steps when you rely on in-cluster Secrets:</p>
<ol>
<li>Enable <a href="https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/">encryption at rest</a> for Secret resources on the API server (managed clouds often expose this as a cluster setting).</li>
<li>Split RBAC so app ServiceAccounts can <code>get</code> only the Secrets they mount. Avoid namespace-wide <code>list</code>/<code>watch</code> on Secrets for workloads.</li>
<li>Prefer volume mounts over env vars when you can. Env vars are easy to leak via <code>printenv</code>, crash dumps, and child processes; files on a tmpfs mount are still sensitive but easier to scope per container.</li>
<li>Never commit live Secret manifests with real <code>data</code> or <code>stringData</code> to Git. Use sealed-secrets, SOPS, External Secrets Operator, or a CI-injected create step.</li>
<li>Rotate credentials after any suspected exposure. Changing the Secret object alone is not enough until Pods restart (or remount and reload).</li>
</ol>
<h2>When to use ConfigMap vs Secret</h2>
<p>Use this decision order when you add a new key to a workload:</p>
<ol>
<li><strong>Would disclosure of this value create a security or compliance incident?</strong> If yes, it belongs in a Secret (or an external secret store), never a ConfigMap.</li>
<li><strong>Is it ordinary runtime config?</strong> Log levels, non-secret URLs, feature flags, and file-based app config belong in a ConfigMap.</li>
<li><strong>Does a Kubernetes controller expect a typed Secret?</strong> Registry pull credentials, TLS pairs, and bootstrap tokens use Secret types. Do not reinvent them as ConfigMaps.</li>
<li><strong>Will many Pods share the same non-secret settings?</strong> One ConfigMap referenced by several Deployments beats copy-pasted env blocks.</li>
<li><strong>Do you need rotation, audit, or cross-cluster sharing?</strong> Stay on Kubernetes Secrets only for simple cases. Escalate to Vault or cloud secret managers when those requirements appear.</li>
</ol>
<p>A practical split for a typical API Deployment:</p>
<table><thead><tr><th>Key</th><th>Object</th></tr></thead><tbody><tr><td><code>APP_ENV</code>, <code>LOG_LEVEL</code>, <code>FEATURE_*</code></td><td>ConfigMap</td></tr><tr><td>Upstream service DNS names (no credentials in the URL)</td><td>ConfigMap</td></tr><tr><td><code>DB_PASSWORD</code>, <code>API_TOKEN</code>, HMAC keys</td><td>Secret</td></tr><tr><td>TLS cert/key for Ingress</td><td>Secret (<code>kubernetes.io/tls</code>)</td></tr><tr><td>Private registry pull auth</td><td>Secret (<code>kubernetes.io/dockerconfigjson</code>)</td></tr></tbody></table>
<p>If you are still choosing tools for how config reaches the cluster (Helm values, GitOps, Ansible), keep the same rule at the source: non-secret values may live in Git; secret values need encryption or an external store before they ever become a Kubernetes object. That boundary pairs cleanly with the provisioning vs configuration split in <a href="https://reclear.io/blog/terraform-vs-ansible-when-to-use-which">Terraform vs Ansible: When to Use Which</a>.</p>
<h2>When this approach breaks down</h2>
<p>In-cluster ConfigMaps and Secrets are the right default for many apps. They stop being enough in a few common cases.</p>
<ol>
<li>
<p><strong>Env var injection and live updates.</strong> Changing a ConfigMap or Secret does not update environment variables in running containers. Mounted files can update later (kubelet sync period), but many processes read config once at startup. Plan on <code>kubectl rollout restart</code> (or a reload signal your app understands) after every sensitive rotation.</p>
</li>
<li>
<p><strong>etcd and API access as the trust boundary.</strong> If an attacker can read etcd or impersonate a principal with Secret <code>get</code>/<code>list</code>, every in-cluster Secret in scope is exposed. Encryption at rest helps against etcd disk theft; it does not help against a compromised API credential with Secret permissions.</p>
</li>
<li>
<p><strong>GitOps with raw Secret manifests.</strong> Declarative delivery fights with confidential data. Teams either encrypt manifests (SOPS, Sealed Secrets) or stop storing values in Git and sync from an external manager. Without one of those patterns, &quot;Secrets in Kubernetes&quot; becomes &quot;Secrets in the repo.&quot;</p>
</li>
<li>
<p><strong>Multi-cluster and compliance-heavy estates.</strong> Per-cluster Opaque Secrets do not give you centralized rotation policies, break-glass audit, or a single inventory of what exists. At that point the Kubernetes Secret is a delivery cache, not the system of record.</p>
</li>
</ol>]]></content:encoded>
      <pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Ilyas Rufai)</author>
      <category>Tutorials</category>
      <category>Kubernetes</category>
      <category>DevOps</category>
      <category>Security</category>
      <enclosure url="https://reclear.io/blog/kubernetes-configmap-vs-secret-explained/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>How to Fix the &quot;ImagePullBackOff&quot; Error in Kubernetes</title>
      <link>https://reclear.io/blog/how-to-fix-imagepullbackoff-error-in-kubernetes</link>
      <guid isPermaLink="true">https://reclear.io/blog/how-to-fix-imagepullbackoff-error-in-kubernetes</guid>
      <description>Learn how to diagnose ImagePullBackOff with kubectl describe, fix wrong image tags and private registry auth, and verify pods reach Running state.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/how-to-fix-imagepullbackoff-error-in-kubernetes/cover/cover.webp" alt="How to Fix the &quot;ImagePullBackOff&quot; Error in Kubernetes" /></p><aside><ul>
<li><code>ImagePullBackOff</code> means the kubelet cannot pull the container image and is retrying with exponential backoff. <code>ErrImagePull</code> is the failed attempt; <code>ImagePullBackOff</code> is the waiting state between retries.</li>
<li>The fix always starts with <code>kubectl describe pod</code> and the <strong>Events</strong> section. The error string there maps directly to the cause: wrong tag, missing <code>imagePullSecrets</code>, rate limit, or network failure.</li>
<li>Wrong image name or tag is the fastest fix: correct <code>spec.containers[].image</code>, then restart the workload.</li>
<li>Private registries need a <code>kubernetes.io/dockerconfigjson</code> Secret referenced in <code>spec.imagePullSecrets</code> or on the ServiceAccount.</li>
<li>After any fix, confirm with <code>kubectl get pods</code> showing <code>Running</code> and <code>1/1</code> Ready.</li>
</ul></aside>
<p>You merged a Deployment, ran <code>kubectl get pods</code>, and every replica is stuck in <code>ImagePullBackOff</code>. The rollout looks green in GitOps, but nothing is serving traffic. You open the dashboard, see a red pod, and still do not know whether the image name is wrong, the registry password expired, or the cluster lost network access to Docker Hub.</p>
<p>In this article, you will reproduce the failure in a sandbox namespace, read the exact error from pod Events, fix the two most common causes (bad image reference and missing registry credentials), and verify the pod reaches <code>Running</code> with screenshots at each step.</p>
<aside><p><strong>What you need:</strong> <code>kubectl</code> 1.28+, access to a cluster (kind, minikube, EKS, GKE, or AKS), and permission to create namespaces, Secrets, and Deployments in a non-production sandbox. The walkthrough uses namespace <code>demo-app</code>.</p></aside>
<h2>Why pods get stuck in ImagePullBackOff</h2>
<p>Kubernetes schedules the pod onto a node. The kubelet asks the container runtime (containerd or CRI-O) to pull the image from the registry in <code>spec.containers[].image</code>. When that pull fails, you see <code>ErrImagePull</code> on the first attempts. After repeated failures, the kubelet backs off and the pod status becomes <code>ImagePullBackOff</code>.</p>
<p>That status does <strong>not</strong> mean the pod crashed. It means the image never arrived. The application container never started.</p>
<table><thead><tr><th>Status</th><th>What it means</th></tr></thead><tbody><tr><td><code>ErrImagePull</code></td><td>A pull attempt failed right now</td></tr><tr><td><code>ImagePullBackOff</code></td><td>Pull failed; kubelet is waiting before the next retry</td></tr><tr><td><code>InvalidImageName</code></td><td>Malformed image string in the pod spec</td></tr><tr><td><code>ImageInspectError</code></td><td>Runtime could not inspect the image after pull</td></tr></tbody></table>
<p>Most production incidents fall into four buckets:</p>
<table><thead><tr><th>Error pattern in Events</th><th>Typical cause</th><th>Fix</th></tr></thead><tbody><tr><td><code>not found</code> / <code>manifest unknown</code></td><td>Wrong repository or tag</td><td>Correct the image field</td></tr><tr><td><code>unauthorized</code> / <code>authentication required</code></td><td>Missing or bad <code>imagePullSecrets</code></td><td>Create or update registry Secret</td></tr><tr><td><code>toomanyrequests</code></td><td>Docker Hub anonymous rate limit</td><td>Authenticate with pull Secret</td></tr><tr><td><code>i/o timeout</code> / <code>no such host</code></td><td>DNS, firewall, or registry outage</td><td>Fix network path to registry</td></tr></tbody></table>
<h2>How to read the error in pod Events</h2>
<p>Do not guess from the Deployment YAML alone. Start with the pod status, then read Events.</p>
<p>Deploy a deliberately broken image so you can see the failure state. This manifest references a tag that does not exist on Docker Hub:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: demo-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
      - name: api
        image: nginx:does-not-exist-2026
        ports:
        - containerPort: 80
</code></pre>
<p>Create the namespace and apply the manifest:</p>
<pre><code class="language-bash">kubectl create namespace demo-app
kubectl apply -f broken-deployment.yaml
</code></pre>
<p>List pods until the status shows <code>ImagePullBackOff</code>:</p>
<pre><code class="language-bash">kubectl get pods -n demo-app -w
</code></pre>
<p>You should see output like this:</p>
<p><img src="https://reclear.io/blog/how-to-fix-imagepullbackoff-error-in-kubernetes/screenshots/kubectl-get-pods-backoff.webp" alt="kubectl get pods showing ImagePullBackOff status for the api pod in demo-app namespace"/></p>
<p>Describe the pod and scroll to <strong>Events</strong>. The <code>Failed</code> line contains the registry error string you need:</p>
<pre><code class="language-bash">kubectl describe pod -n demo-app -l app=api
</code></pre>
<p><img src="https://reclear.io/blog/how-to-fix-imagepullbackoff-error-in-kubernetes/screenshots/kubectl-describe-events-not-found.webp" alt="kubectl describe pod Events section showing ErrImagePull and manifest not found for nginx:does-not-exist-2026"/></p>
<p>In this example, <code>not found</code> means the tag does not exist. The fix is not to restart the node. It is to correct the image reference.</p>
<h2>How to fix a wrong image name or tag</h2>
<p>Confirm the image exists before you change the cluster. For public images on Docker Hub:</p>
<pre><code class="language-bash">docker pull nginx:1.25-alpine
</code></pre>
<p>If that fails locally, the tag is wrong or the registry is unreachable from your network.</p>
<p>Patch the Deployment to a valid image:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: demo-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      containers:
      - name: api
        image: nginx:1.25-alpine
        ports:
        - containerPort: 80
</code></pre>
<p>Apply the fix and restart the rollout so new pods pull immediately:</p>
<pre><code class="language-bash">kubectl apply -f fixed-deployment.yaml
kubectl rollout restart deployment/api -n demo-app
kubectl rollout status deployment/api -n demo-app
</code></pre>
<p>Watch the pod transition to <code>Running</code>:</p>
<pre><code class="language-bash">kubectl get pods -n demo-app
</code></pre>
<p><img src="https://reclear.io/blog/how-to-fix-imagepullbackoff-error-in-kubernetes/screenshots/kubectl-get-pods-running.webp" alt="kubectl get pods showing Running status and 1/1 Ready after the image tag fix"/></p>
<p>If the pod still shows <code>ImagePullBackOff</code>, describe it again. A typo in the new tag produces the same error string with a different image name.</p>
<h2>How to fix private registry authentication</h2>
<p>When the Events line says <code>unauthorized</code> or <code>authentication required</code>, the image reference is probably correct but the cluster has no credentials for a private registry.</p>
<p>Run <code>kubectl describe pod</code> on the failing workload. The Events section for a missing pull Secret looks like this:</p>
<pre><code class="language-text">Events:
  Type     Reason     Age   From     Message
  ----     ------     ----   ----     -------
  Warning  Failed     21s   kubelet  Failed to pull image &quot;registry.example.com/api:v2&quot;: rpc error: code = Unknown desc = failed to pull and unpack image &quot;registry.example.com/api:v2&quot;: failed to resolve reference &quot;registry.example.com/api:v2&quot;: pull access denied, repository does not exist or may require authorization: server message: unauthorized: authentication required
</code></pre>
<div><h3>Create the registry pull Secret</h3><p>Replace the registry host, username, and password with your values. The Secret must live in the <strong>same namespace</strong> as the pod.</p><pre><code class="language-bash">kubectl create secret docker-registry regcred \
  --docker-server=registry.example.com \
  --docker-username=ci-bot \
  --docker-password=&#x27;YOUR_TOKEN&#x27; \
  --docker-email=ci-bot@example.com \
  -n demo-app
</code></pre><p><strong>Verify:</strong> <code>kubectl get secret regcred -n demo-app</code> shows type <code>kubernetes.io/dockerconfigjson</code>.</p><h3>Attach the Secret to the workload</h3><p>Reference the Secret in the pod spec (or on the ServiceAccount every pod in the namespace uses):</p><pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: demo-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      imagePullSecrets:
      - name: regcred
      containers:
      - name: api
        image: registry.example.com/api:v2
        ports:
        - containerPort: 80
</code></pre><p>Apply and restart:</p><pre><code class="language-bash">kubectl apply -f deployment-with-pull-secret.yaml
kubectl rollout restart deployment/api -n demo-app
</code></pre><p><strong>Verify:</strong> <code>kubectl describe pod -n demo-app -l app=api</code> shows <code>Pulled</code> and <code>Created</code> events without <code>unauthorized</code>.</p><h3>Patch the ServiceAccount when many Deployments share a namespace</h3><p>Attaching the Secret to the default ServiceAccount avoids repeating <code>imagePullSecrets</code> in every manifest:</p><pre><code class="language-bash">kubectl patch serviceaccount default -n demo-app \
  -p &#x27;{&quot;imagePullSecrets&quot;: [{&quot;name&quot;: &quot;regcred&quot;}]}&#x27;
</code></pre><p><strong>Verify:</strong> <code>kubectl get sa default -n demo-app -o yaml</code> lists <code>regcred</code> under <code>imagePullSecrets</code>.</p></div>
<aside><p>Rotating registry tokens requires recreating the Secret (<code>kubectl delete secret regcred</code> then <code>create</code> again) or using a secrets operator. Updating only the Deployment image does not refresh expired credentials.</p></aside>
<h2>How to verify the pod is healthy</h2>
<p>A fixed pull ends with three signals:</p>
<ol>
<li><strong>Pod phase:</strong> <code>kubectl get pods -n demo-app</code> shows <code>STATUS</code> = <code>Running</code> and <code>READY</code> = <code>1/1</code>.</li>
<li><strong>Events:</strong> <code>kubectl describe pod</code> shows <code>Pulled</code>, <code>Created</code>, and <code>Started</code> without new <code>Failed</code> pull events.</li>
<li><strong>Application check:</strong> Port-forward or hit the Service to confirm the process responds:</li>
</ol>
<pre><code class="language-bash">kubectl port-forward -n demo-app deployment/api 8080:80
curl -I http://127.0.0.1:8080
</code></pre>
<p>You should receive an HTTP response from Nginx (or your application health endpoint).</p>
<h2>When these fixes do not apply</h2>
<p><code>ImagePullBackOff</code> is always an image delivery problem, but not every delivery problem is a wrong tag or missing Secret.</p>
<ul>
<li><strong>Self-signed registry TLS:</strong> The runtime may reject the certificate before auth runs. You need CA trust configured on nodes (containerd <code>certs.d</code> or cluster-specific registry config), not only an <code>imagePullSecrets</code> entry.</li>
<li><strong><code>imagePullPolicy: Never</code> with a missing local image:</strong> The kubelet will not pull from a registry. The image must already exist on the node under the exact name.</li>
<li><strong>Architecture mismatch:</strong> Pull succeeds but the container fails to start with <code>exec format error</code>. That is a different failure mode after pull; fix the image build target platform.</li>
<li><strong>Air-gapped clusters:</strong> No route to public registries. Mirror images to an internal registry and update all manifests to that host.</li>
</ul>
<p>For container networking after the pod is running, see <a href="https://reclear.io/blog/docker-ports-vs-expose">Docker <code>ports</code> vs <code>expose</code></a> for how port publishing differs between Docker and Kubernetes Service types.</p>
<p>For authoritative reference, see the Kubernetes docs on <a href="https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/">pulling an image from a private registry</a> and <a href="https://kubernetes.io/docs/tasks/debug/debug-application/debug-pods/">debugging pods</a>.</p>]]></content:encoded>
      <pubDate>Sat, 04 Jul 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Ilyas Rufai)</author>
      <category>Tutorials</category>
      <category>Kubernetes</category>
      <category>DevOps</category>
      <category>Containers</category>
      <enclosure url="https://reclear.io/blog/how-to-fix-imagepullbackoff-error-in-kubernetes/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>What Makes Great API Documentation (With Real Examples)</title>
      <link>https://reclear.io/blog/what-makes-great-api-documentation</link>
      <guid isPermaLink="true">https://reclear.io/blog/what-makes-great-api-documentation</guid>
      <description>Learn what separates great API documentation from the kind that loses developers, with real examples from Stripe, GitHub, and Paystack showing each principle in action.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/what-makes-great-api-documentation/cover/cover.webp" alt="What Makes Great API Documentation (With Real Examples)" /></p><aside><ul>
<li>Great API documentation leads with a getting-started path that gets developers to their first successful request in under five minutes, not a comprehensive reference.</li>
<li>Stripe, GitHub, and Paystack structure their docs around the developer journey: quick start first, reference second, conceptual guides for the decisions that matter in production.</li>
<li>Error handling is the area API documentation teams most consistently get wrong: each error code needs a documented cause and a clear recovery step, not just an HTTP status and a vague message.</li>
<li>Consistency in endpoint naming, response shapes, and terminology is an API design decision, not a writing decision. Documentation cannot fix an inconsistent API surface.</li>
</ul></aside>
<p>You&#x27;ve built a clean API. The endpoints are well-structured, authentication is straightforward, and the rate limits make sense. Then you watch a developer try to integrate it for the first time. They land on your docs, hunt for an authentication example, bounce between three pages trying to understand the token format, hit an undocumented 422 error, and give up. The API wasn&#x27;t the problem. The documentation was.</p>
<p>In this article, we break down the five characteristics that separate great API documentation from the kind that quietly loses developers at first contact, with concrete examples from Stripe, GitHub, and Paystack showing what each characteristic looks like when applied well.</p>
<h2>Why API documentation matters</h2>
<p>When developers evaluate an API, they don&#x27;t read the entire reference before starting. They open the docs, look for a getting-started guide, try to make one call, and form a judgment about the entire product based on how that goes. The documentation is the first version of your API that any developer experiences. They encounter it before writing a single line of code against it.</p>
<p>The cost compounds quickly. Every unanswered question becomes a support ticket. Every undocumented error becomes a debugging session. Every missing code example becomes an hour of trial and error. SmartBear&#x27;s <a href="https://swagger.io/blog/api-documentation-guide/">State of API documentation research</a> consistently identifies poor documentation as the top complaint developers raise when integrating with third-party APIs: above pricing, performance, and even reliability. Teams with comparable APIs do not always win integrations on technical merit. The teams with documentation that gets developers to success first often do.</p>
<h2>Help developers make a successful first request</h2>
<p>The single most important page in any API documentation set is the getting-started guide: not the reference, not the authentication overview. The getting-started guide is where developers decide if your API is worth the next hour of their time, and it must answer one question fast: how do I make a successful request right now?</p>
<p>A functional getting-started guide covers exactly four things in sequence: how to create an account and access credentials, how to make an authenticated request (with a copy-paste-ready example), how to interpret the response, and where to go next. Anything beyond those four elements belongs in the reference or a dedicated tutorial, not the getting-started flow.</p>
<p>GitHub&#x27;s <a href="https://docs.github.com/en/rest/quickstart">quickstart for the REST API</a> illustrates this well. It offers three entry points: GitHub CLI, curl, and JavaScript via Octokit. A developer using any stack can authenticate and make a <code>GET /octocat</code> request in under ten steps, and the docs guide each path explicitly without requiring the developer to read conceptual material before seeing the API respond. The page is structured around what the developer needs to do, not what GitHub wants to explain.</p>
<p>Paystack applies the same principle. Their developer documentation organizes content into five clearly labeled categories (accepting payments, sending money, identifying customers, libraries and plugins, and guides) so a developer arriving for a specific task can find the right path without reading the homepage end to end. Interactive walkthroughs walk new developers through initializing a transaction, redirecting to checkout, and verifying a payment completion. &quot;In a nutshell&quot; summaries on each page let experienced developers skip context and get to implementation immediately.</p>
<h2>Make reference documentation easy to scan</h2>
<p>A developer navigating your reference is not reading linearly. They have a mental model of what they need, and they&#x27;re matching that model to what they see. Reference documentation must support that scanning behavior rather than require sequential reading.</p>
<p>Each endpoint entry needs a consistent, predictable shape: the HTTP method and path at the top, a one-sentence description of what the endpoint does, a parameter table that distinguishes required from optional fields, a response schema with types, and at least one complete example request and response. Any deviation from that structure forces the developer to reorient with every new endpoint they check. That reorientation cost adds up.</p>
<p>GitHub&#x27;s REST API reference holds to this rigorously. Every endpoint follows an identical structure. The parameter table marks required fields explicitly. The response schema shows types alongside descriptions. A developer who has read one endpoint page knows exactly where to look on every other endpoint without re-learning the layout. That consistency doesn&#x27;t come from careful writing alone. It&#x27;s generated from a structured OpenAPI specification, which ensures the documentation and the API surface stay in sync.</p>
<aside><p>Parameter tables should always mark the &quot;required&quot; versus &quot;optional&quot; distinction prominently, either as a column in the table or as inline labels. Developers scanning for what they must include will miss it if it&#x27;s buried in a prose description.</p></aside>
<h2>Use production-ready code examples</h2>
<p>The most common failure mode in API documentation code examples is placeholder data. When a payments API shows <code>amount: 1000</code> with <code>customer: &quot;test_user&quot;</code>, the developer reading that example has to mentally translate it into their actual payload structure. During that translation, they frequently misunderstand how the API behaves with real data: whether amounts are in whole units or smallest currency denomination, whether IDs are integers or UUIDs, whether emails need to be verified accounts.</p>
<p>Good code examples use realistic data, cover the paths developers encounter in production (not only the success case), and exist in every language where the API sees meaningful adoption. Every snippet must be copy-pasteable and produce the documented output in a clean environment.</p>
<p>Stripe&#x27;s API reference sets a clear standard here. The reference provides language-specific examples in Python, Ruby, Node.js, PHP, Java, Go, and .NET for every endpoint, all accessible from a single language switcher. Each example uses the Stripe SDK rather than raw HTTP, which mirrors how developers will actually implement it. When optional parameters change behavior in production (such as <code>capture_method: &quot;manual&quot;</code> for delayed charge capture), those variants are shown explicitly alongside the default example.</p>
<p>Paystack handles this at the project level. Their sample projects are downloadable, runnable codebases scoped to specific use cases: a React storefront for standard checkout, a Vue app for recurring billing, a mobile webview integration for Android. A developer who needs to see how a recurring payment flow works can clone and run the sample without building scaffolding from scratch. The documentation points to the real code rather than describing it abstractly.</p>
<section><section><h3>Node.js</h3><pre><code class="language-javascript">// Initialize a Paystack transaction from your backend
const response = await fetch(&quot;https://api.paystack.co/transaction/initialize&quot;, {
  method: &quot;POST&quot;,
  headers: {
    Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
    &quot;Content-Type&quot;: &quot;application/json&quot;,
  },
  body: JSON.stringify({
    email: &quot;customer@example.com&quot;,
    amount: 5000, // Amount in kobo (NGN 50.00)
    reference: `txn_${Date.now()}`,
  }),
});

const { data } = await response.json();
// data.authorization_url — redirect the customer here to complete payment
// data.access_code — use this with Popup JS to open the payment modal inline
console.log(data.authorization_url);
</code></pre></section><section><h3>Python</h3><pre><code class="language-python">import os
import time
import requests

# Initialize a Paystack transaction from your backend
response = requests.post(
    &quot;https://api.paystack.co/transaction/initialize&quot;,
    headers={
        &quot;Authorization&quot;: f&quot;Bearer {os.environ[&#x27;PAYSTACK_SECRET_KEY&#x27;]}&quot;,
        &quot;Content-Type&quot;: &quot;application/json&quot;,
    },
    json={
        &quot;email&quot;: &quot;customer@example.com&quot;,
        &quot;amount&quot;: 5000,  # Amount in kobo (NGN 50.00)
        &quot;reference&quot;: f&quot;txn_{int(time.time())}&quot;,
    },
)

data = response.json()[&quot;data&quot;]
# data[&quot;authorization_url&quot;] — redirect the customer here to complete payment
print(data[&quot;authorization_url&quot;])
</code></pre></section></section>
<h2>Document recovery, not just errors</h2>
<p>An API returns a <code>422 Unprocessable Entity</code>. The error message reads: <code>Invalid request payload</code>. The developer stares at a request they believe is well-formed. What do they do?</p>
<p>Without a recovery step, that error is noise. The developer opens a new tab, searches the error code, finds a Stack Overflow thread from 2019, and spends forty minutes eliminating possibilities one at a time. This is where bad error documentation reveals itself: it describes what went wrong without telling the developer what to do about it.</p>
<p>Stripe&#x27;s error documentation is the clearest example of this handled correctly. Every error code has a dedicated entry in Stripe&#x27;s <a href="https://docs.stripe.com/error-codes">error codes reference</a>. When an API error occurs, the response object includes a <code>doc_url</code> field that links directly to that specific error code&#x27;s entry, so the developer doesn&#x27;t have to hunt for it. Stripe&#x27;s server-side SDKs go further by mapping error categories to distinct exception types: <code>CardError</code>, <code>InvalidRequestError</code>, <code>AuthenticationError</code>, <code>RateLimitError</code>, and others. Developers can handle each category with specific logic rather than inspecting error strings.</p>
<pre><code class="language-ruby"># Stripe separates error types so you can handle each category independently
begin
  client.payment_intents.create(params)
rescue Stripe::CardError =&gt; e
  # The card was declined — show the user a friendly message
  puts &quot;Payment declined: #{e.error.message}&quot;
rescue Stripe::InvalidRequestError =&gt; e
  # A request parameter was missing or malformed — fix the payload
  puts &quot;Bad request: #{e.error.message} (param: #{e.error.param})&quot;
rescue Stripe::RateLimitError =&gt; e
  # Too many requests — implement exponential backoff
  puts &quot;Rate limited — retry after delay (request ID: #{e.request_id})&quot;
rescue Stripe::AuthenticationError =&gt; e
  # Invalid API key — check environment configuration
  puts &quot;Authentication failed (request ID: #{e.request_id})&quot;
end
</code></pre>
<p>Paystack&#x27;s API reference surfaces a similar pattern at the endpoint level. Each endpoint documents its error responses alongside the endpoint itself, including the specific conditions that produce each error. A developer reading the <a href="https://paystack.com/docs/api/">transaction initialization endpoint</a> sees its failure cases in the same place as its success response, not in a separate &quot;Errors&quot; section they have to navigate to separately.</p>
<p>The principle that applies to both: every error entry needs three elements (the condition that causes it, the specific message the developer will see, and the step to take to resolve it). The implementation patterns behind a consistent error schema, including typed exception classes, the four-question error contract, and multi-field validation, are covered in <a href="https://reclear.io/blog/api-error-handling-best-practices">API error handling best practices for developers</a>.</p>
<h2>Teach the reasoning behind the API</h2>
<p>Reference documentation tells developers what an endpoint does. Conceptual guides tell them why the API is structured the way it is, what trade-offs the design reflects, and when to use one approach over another. Without that layer, developers make integration decisions that work in testing and break under production load.</p>
<p>A conceptual guide for authentication, for example, should explain the difference between API keys and OAuth tokens, when each is appropriate, and what the security implications are for each. A guide on webhooks should explain the delivery guarantee model and why <a href="https://reclear.io/blog/webhook-handlers-best-practices-retry-patterns">idempotent webhook handlers</a> matter before a developer writes their first event handler. Without that context, the developer makes the right technical call by chance rather than by understanding.</p>
<p>GitHub&#x27;s developer documentation handles this well across its REST and GraphQL sections. Each major feature area has a conceptual overview that explains the model before asking developers to make configuration decisions. The guide on <a href="https://docs.github.com/en/rest/using-the-rest-api/getting-started-with-the-rest-api">authentication</a> explains the difference between fine-grained personal access tokens and classic tokens, what scopes and permissions each supports, and which to choose for different integration types. Developers don&#x27;t just learn what to click: they understand why the decision matters for the security posture of their integration.</p>
<p>Paystack&#x27;s guide on <a href="https://paystack.com/docs/payments/accept-payments/">accepting payments</a> does the same for transaction initialization. Rather than jumping straight to the API call, the documentation explains why the transaction must be initialized from the backend rather than the frontend, what the <code>access_code</code> is for versus the <code>authorization_url</code>, and when to use Popup JS versus a redirect flow. Those are real architectural decisions developers will make when integrating, and the guide gives them the context to make them correctly.</p>
<h2>What this looks like in practice</h2>
<p>The principles above become easier to evaluate when applied to real documentation sets. Stripe, GitHub, and Paystack approach developer experience differently, but all three organize documentation around helping developers reach a working implementation quickly.</p>
<ol>
<li>
<p>Stripe prioritizes implementation speed. Developers encounter copy-paste-ready examples, language-specific SDK snippets, and error documentation that includes direct recovery guidance.</p>
</li>
<li>
<p>GitHub balances conceptual guidance with exhaustive reference material. Authentication, permissions, and API design decisions are explained before developers are asked to make implementation choices.</p>
</li>
<li>
<p>Paystack structures documentation around common business tasks such as accepting payments, verifying transactions, and managing customers. Developers can navigate by outcome rather than by endpoint category.</p>
</li>
</ol>
<p>Although the details differ, all three documentation sets share the same pattern: quick start first, reference second, and conceptual guidance for the decisions that matter in production.</p>
<h2>Avoid patterns that break developer trust</h2>
<p>Most API documentation fails not because of missing content but because of structural decisions that make existing content hard to find, hard to trust, or hard to apply.</p>
<p>The most common failure is opening with the reference instead of a tutorial. Developers who land on a reference before they&#x27;ve made their first successful request cannot extract value from it because the context isn&#x27;t there yet. The reference becomes useful only after developers have a working mental model of how the API responds. Leading with it is backwards.</p>
<p>Using unrealistic sample data is the second most common failure. When every example uses <code>user_id: 12345</code>, <code>amount: 100</code>, and <code>email: test@example.com</code>, developers can&#x27;t tell from the example whether amounts are in whole units or smallest currency denomination, whether IDs are integers or universally unique (UUIDs), or what constraints apply to each field. Realistic sample data carries information that abstract placeholders don&#x27;t.</p>
<p>Missing or buried authentication examples follow closely. Every developer integrating an API must authenticate, and the authentication example belongs in the getting-started guide, not the reference. When authentication is documented as a footnote or referenced without an example, it creates friction at the exact moment developers are most likely to abandon the integration.</p>
<p>Poor navigation makes all three worse. A search function that returns broad, unranked results forces developers to scan pages manually. A navigation structure that doesn&#x27;t reflect how developers think about the problem (task-first rather than endpoint-first) means developers spend time navigating instead of building.</p>
<h2>When this breaks down</h2>
<p>The practices in this article improve documentation quality, but they cannot compensate for deeper product problems.</p>
<ol>
<li>
<p>Documentation cannot fully cover for an inconsistent API surface. If endpoint naming, response structures, or error behavior vary unpredictably across the platform, developers will still encounter friction regardless of how well the documentation is written.</p>
</li>
<li>
<p>Documentation cannot replace missing capabilities. Features such as idempotency keys, webhook signature verification, or cursor-based pagination must exist in the product itself before they can be documented effectively.</p>
</li>
<li>
<p>Documentation becomes harmful when it falls out of date. A well-written guide describing outdated behavior creates false confidence and can cause production failures. Documentation must evolve alongside the API.</p>
</li>
</ol>
<p>The strongest documentation teams treat documentation as part of the product surface, not as a publishing exercise. Every API change, error response, and workflow update should trigger a documentation review alongside the engineering work.</p>]]></content:encoded>
      <pubDate>Thu, 25 Jun 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (AbdulRaheem Olurode)</author>
      <category>Documentation</category>
      <category>DeveloperExperience</category>
      <category>TechnicalWriting</category>
      <category>DX</category>
      <enclosure url="https://reclear.io/blog/what-makes-great-api-documentation/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>15 Docker Commands Every Developer Should Know (With Examples)</title>
      <link>https://reclear.io/blog/docker-commands-every-developer-should-know</link>
      <guid isPermaLink="true">https://reclear.io/blog/docker-commands-every-developer-should-know</guid>
      <description>Learn how to run, debug, build, and clean up Docker workloads with the 15 CLI commands developers reach for most, grouped by workflow with copy-paste examples.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/docker-commands-every-developer-should-know/cover/cover.webp" alt="15 Docker Commands Every Developer Should Know (With Examples)" /></p><aside><ul>
<li>Fifteen commands cover most daily Docker work: <strong>run, ps, stop, start, rm</strong> for container lifecycle; <strong>exec, logs, inspect</strong> for debugging; <strong>build, images, pull, rmi</strong> for images; <strong>compose up, compose down</strong> for multi-service apps; <strong>system prune</strong> for disk recovery.</li>
<li>Reach for <code>docker run</code> when you need a new container, <code>docker start</code> when you are resuming one that already exists, and <code>docker compose</code> when the app has more than one service.</li>
<li>The failure mode is not missing obscure flags. It is using <code>docker run</code> for everything, never pruning disk, and debugging production issues without <code>docker logs</code> and <code>docker inspect</code>.</li>
<li>Modern Docker uses the <code>docker compose</code> plugin (V2). The standalone <code>docker-compose</code> binary is legacy; examples below use the integrated command.</li>
</ul></aside>
<p>You cloned a repo with a <code>Dockerfile</code> and a <code>compose.yml</code>, ran one command from a blog post three months ago, and now you are staring at <code>Error response from daemon: Conflict. The container name &quot;/api&quot; is already in use</code> with no idea whether to remove, restart, or rebuild. Docker&#x27;s CLI has hundreds of subcommands. You do not need hundreds. You need the small set that shows up in every standup, incident, and local dev session.</p>
<p>In this article, you will get 15 Docker commands ranked by how often working developers actually use them, with copy-paste examples, the flags that matter, and the mistakes each command helps you avoid.</p>
<aside><p><strong>What you need:</strong> Docker Engine 24+ or Docker Desktop 4.25+ with the Compose V2 plugin (<code>docker compose version</code> should work). All examples assume a Linux or macOS shell. On Windows, use WSL2 or Git Bash for identical syntax.</p></aside>
<h2>Why most Docker cheat sheets fail you</h2>
<p>Most cheat sheets list 40 commands alphabetically. That is fine as a poster. It is useless when a container exits immediately and you need logs, or when disk usage hits 100% and Docker refuses to build.</p>
<p>The 15 commands below are ordered by workflow, not alphabet. They are the overlap across Docker&#x27;s official docs, production cheat sheets, and the commands teams re-type daily: lifecycle first, then debugging, then images, then Compose, then cleanup. If you internalize these, you can handle a new microservice repo in minutes instead of re-googling flags.</p>
<table><thead><tr><th>Workflow</th><th>Commands</th><th>What you are trying to do</th></tr></thead><tbody><tr><td>Run containers</td><td><code>run</code>, <code>ps</code>, <code>stop</code>, <code>start</code>, <code>rm</code></td><td>Create, list, pause, resume, and delete containers</td></tr><tr><td>Debug containers</td><td><code>exec</code>, <code>logs</code>, <code>inspect</code></td><td>Shell in, read output, read metadata</td></tr><tr><td>Manage images</td><td><code>build</code>, <code>images</code>, <code>pull</code>, <code>rmi</code></td><td>Build, list, download, and delete images</td></tr><tr><td>Multi-service apps</td><td><code>compose up</code>, <code>compose down</code></td><td>Start and stop stacks from YAML</td></tr><tr><td>Disk recovery</td><td><code>system prune</code></td><td>Reclaim space from orphaned objects</td></tr></tbody></table>
<h2>Commands for running containers</h2>
<p>These five commands cover the container lifecycle from first launch to removal.</p>
<p><strong>1. <code>docker run</code></strong></p>
<p><code>docker run</code> creates and starts a container from an image. It is the command you use when nothing exists yet.</p>
<p>The example below runs Nginx detached, maps host port 8080 to container port 80, names the container, and auto-removes it when it stops:</p>
<pre><code class="language-bash">docker run -d \
  --name web \
  -p 8080:80 \
  --rm \
  nginx:1.25-alpine
</code></pre>
<p>Common flags worth memorizing:</p>
<ul>
<li><code>-d</code>: detached (background)</li>
<li><code>-p HOST:CONTAINER</code>: publish a port to your machine (<a href="https://reclear.io/blog/docker-ports-vs-expose">see ports vs expose</a> for when this differs from internal-only networking)</li>
<li><code>--name</code>: stable name for <code>stop</code>, <code>logs</code>, and <code>exec</code></li>
<li><code>--rm</code>: delete the container on exit (great for one-off tests)</li>
<li><code>-e KEY=val</code> / <code>--env-file .env</code>: inject environment variables</li>
<li><code>-v HOST_PATH:CONTAINER_PATH</code>: bind-mount code or data for local dev</li>
</ul>
<p><strong>2. <code>docker ps</code></strong></p>
<p><code>docker ps</code> lists running containers. Add <code>-a</code> to include stopped containers, which is what you need when something exited and you want its name back.</p>
<pre><code class="language-bash">docker ps
docker ps -a --filter &quot;name=web&quot;
</code></pre>
<p>Use <code>--format &quot;table {{.Names}}\t{{.Status}}\t{{.Ports}}&quot;</code> when the default table hides the port mapping you care about.</p>
<p><strong>3. <code>docker stop</code></strong></p>
<p><code>docker stop</code> sends SIGTERM and waits for graceful shutdown before SIGKILL. Use it instead of deleting a container when you plan to start it again.</p>
<pre><code class="language-bash">docker stop web
docker stop -t 30 web   # wait up to 30 seconds before SIGKILL
</code></pre>
<p><strong>4. <code>docker start</code></strong></p>
<p><code>docker start</code> resumes a stopped container with the same filesystem, env, and name. It does not re-read your image or Dockerfile.</p>
<pre><code class="language-bash">docker start web
docker start -a web     # start and attach stdout/stderr to your terminal
</code></pre>
<p><strong>5. <code>docker rm</code></strong></p>
<p><code>docker rm</code> deletes a stopped container. You cannot remove a running container unless you pass <code>-f</code>.</p>
<pre><code class="language-bash">docker rm web
docker rm -f web        # force-remove a running container
docker container prune  # remove all stopped containers (narrower than system prune)
</code></pre>
<h2>Commands for debugging containers</h2>
<p>When a container crashes on boot or behaves differently in Docker than on your host, these three commands are the first line of defense.</p>
<p><strong>6. <code>docker exec</code></strong></p>
<p><code>docker exec</code> runs a command inside a <strong>running</strong> container. The <code>-it</code> flags give you an interactive shell.</p>
<pre><code class="language-bash">docker exec -it web sh
docker exec web nginx -t   # run a one-off command without a shell
</code></pre>
<p>If <code>exec</code> fails with &quot;container is not running,&quot; check <code>docker ps -a</code> and read <code>docker logs</code> first. The container likely exited before you tried to shell in.</p>
<p><strong>7. <code>docker logs</code></strong></p>
<p><code>docker logs</code> prints stdout and stderr from the container&#x27;s main process. This is how you see stack traces, bind errors, and migration failures.</p>
<pre><code class="language-bash">docker logs web
docker logs -f --tail 100 web   # follow the last 100 lines live
docker logs --since 10m web     # only the last 10 minutes
</code></pre>
<p><strong>8. <code>docker inspect</code></strong></p>
<p><code>docker inspect</code> returns full JSON metadata: IP address, mount points, env vars, network mode, and health check state. Use <code>--format</code> to pull one field without scrolling.</p>
<pre><code class="language-bash">docker inspect web
docker inspect --format &#x27;{{.State.Status}}&#x27; web
docker inspect --format &#x27;{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}&#x27; web
</code></pre>
<h2>Commands for building and shipping images</h2>
<p>Images are the artifact <code>docker run</code> consumes. These four commands cover local builds and registry workflows.</p>
<p><strong>9. <code>docker build</code></strong></p>
<p><code>docker build</code> creates an image from a Dockerfile in the build context (usually <code>.</code>).</p>
<pre><code class="language-bash">docker build -t myapp:local .
docker build -t myapp:local -f Dockerfile.prod .
docker build --no-cache -t myapp:local .   # ignore layer cache
</code></pre>
<p>Tag with a registry prefix before you push: <code>myregistry.example.com/myapp:v1.0</code>.</p>
<p><strong>10. <code>docker images</code></strong></p>
<p><code>docker images</code> lists images on your machine. Use it before <code>rmi</code> so you do not delete the wrong tag.</p>
<pre><code class="language-bash">docker images
docker images myapp
docker images --filter &quot;dangling=true&quot;   # untagged intermediate layers
</code></pre>
<p><strong>11. <code>docker pull</code></strong></p>
<p><code>docker pull</code> downloads an image from a registry (Docker Hub by default). Pin a digest or version tag instead of <code>latest</code> in production pipelines.</p>
<pre><code class="language-bash">docker pull nginx:1.25-alpine
docker pull myregistry.example.com/myapp:v1.0
</code></pre>
<p><strong>12. <code>docker rmi</code></strong></p>
<p><code>docker rmi</code> removes a local image. It fails if a running container still references that image unless you remove the container first.</p>
<pre><code class="language-bash">docker rmi myapp:local
docker rmi -f myapp:local    # force when multiple tags point at the same layer
docker image prune -a        # remove images not used by any container
</code></pre>
<h2>Commands for multi-container apps</h2>
<p>Single-container tutorials hide the reality of modern apps: an API, a database, and a cache on a shared network. Compose encodes that stack in YAML so <code>docker compose up</code> replaces five manual <code>docker run</code> commands.</p>
<p><strong>13. <code>docker compose up</code></strong></p>
<p><code>docker compose up</code> reads <code>compose.yml</code> (or <code>docker-compose.yml</code>) and creates networks, volumes, and containers for every service.</p>
<pre><code class="language-bash">docker compose up -d              # detached
docker compose up -d --build        # rebuild images before start
docker compose logs -f api        # tail one service after startup
</code></pre>
<p><strong>14. <code>docker compose down</code></strong></p>
<p><code>docker compose down</code> stops and removes containers, networks, and the default network created for the project. Add <code>-v</code> only when you intend to delete named volumes (database data).</p>
<pre><code class="language-bash">docker compose down
docker compose down -v            # also remove named volumes (destructive)
</code></pre>
<h2>Commands for reclaiming disk</h2>
<p>Docker&#x27;s layer cache and stopped containers accumulate fast on laptops and CI runners. When <code>docker build</code> fails with &quot;no space left on device,&quot; this is the command you need.</p>
<p><strong>15. <code>docker system prune</code></strong></p>
<p><code>docker system prune</code> removes stopped containers, unused networks, dangling images, and build cache.</p>
<pre><code class="language-bash">docker system prune               # interactive prompt
docker system prune -f            # skip prompt
docker system prune -a --volumes  # aggressive: unused images + volumes
</code></pre>
<p>Start with plain <code>docker system prune</code>. Escalate to <code>-a</code> only when you accept re-pulling images on the next build.</p>
<h2>How to verify the workflow</h2>
<p>Run this sequence once on a throwaway project to confirm the commands connect:</p>
<ol>
<li><code>docker pull nginx:1.25-alpine</code></li>
<li><code>docker run -d --name verify-nginx -p 8080:80 nginx:1.25-alpine</code></li>
<li><code>docker ps</code> shows <code>verify-nginx</code> with <code>0.0.0.0:8080-&gt;80/tcp</code></li>
<li><code>curl -I localhost:8080</code> returns HTTP headers from Nginx</li>
<li><code>docker logs verify-nginx</code> shows the Nginx access log line from your curl</li>
<li><code>docker stop verify-nginx &amp;&amp; docker rm verify-nginx</code></li>
</ol>
<p>If you have a <code>compose.yml</code> in a repo, replace steps 2 through 6 with <code>docker compose up -d</code>, <code>docker compose ps</code>, <code>docker compose logs</code>, and <code>docker compose down</code>.</p>
<h2>When these commands are not enough</h2>
<p>These 15 commands assume single-host Docker Engine. They do not cover:</p>
<ul>
<li><strong>Kubernetes:</strong> <code>kubectl</code> replaces most of this CLI once workloads move to a cluster. Docker Desktop still uses these commands locally before you push images.</li>
<li><strong>Multi-platform builds:</strong> <code>docker buildx build --platform linux/amd64,linux/arm64</code> is required when Apple Silicon laptops build images for AMD64 servers.</li>
<li><strong>Secrets and production hardening:</strong> Swarm secrets, rootless Docker, and image signing live outside daily dev workflows but matter in production.</li>
<li><strong>Root cause inside the app:</strong> <code>docker logs</code> shows that the process crashed. It does not replace a debugger inside your language runtime.</li>
</ul>
<p>For networking decisions that affect <code>-p</code> and internal service communication, read <a href="https://reclear.io/blog/docker-ports-vs-expose">Docker <code>ports</code> vs <code>expose</code></a> next.</p>
<p>For authoritative reference, see the <a href="https://docs.docker.com/reference/cli/docker/">Docker CLI documentation</a> and the <a href="https://docs.docker.com/reference/compose-file/">Compose file reference</a>.</p>]]></content:encoded>
      <pubDate>Sat, 20 Jun 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Ilyas Rufai)</author>
      <category>Tutorials</category>
      <category>Docker</category>
      <category>DevOps</category>
      <category>Containers</category>
      <enclosure url="https://reclear.io/blog/docker-commands-every-developer-should-know/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Terraform vs Ansible: When to Use Which</title>
      <link>https://reclear.io/blog/terraform-vs-ansible-when-to-use-which</link>
      <guid isPermaLink="true">https://reclear.io/blog/terraform-vs-ansible-when-to-use-which</guid>
      <description>Learn how to choose between Terraform and Ansible for cloud provisioning, server configuration, and Day 2 operations using a production decision framework and handoff pattern.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/terraform-vs-ansible-when-to-use-which/cover/cover.webp" alt="Terraform vs Ansible: When to Use Which" /></p><aside><ul>
<li>Terraform owns <strong>cloud resource lifecycle</strong>: networks, subnets, compute instances, managed databases, IAM, load balancers, DNS, and anything you need to create, update, and destroy with a tracked dependency graph.</li>
<li>Ansible owns <strong>mutable configuration on existing hosts</strong>: packages, config files, services, users, patches, and multi-step deploy orchestration across a fleet.</li>
<li>The wrong split is using Ansible to provision complex cloud stacks without state tracking, or using Terraform provisioners to configure software inside instances.</li>
<li>Most brownfield VM stacks use a <strong>sequential handoff</strong>: Terraform applies infrastructure and exports connection metadata; Ansible consumes those outputs through dynamic inventory. Pick one tool per layer.</li>
</ul></aside>
<p>Your platform team inherited a repo where Ansible playbooks create VPCs, RDS instances, and security groups, while a separate Terraform stack uses <code>remote-exec</code> provisioners to install Nginx and deploy application code. Neither team can answer two questions reliably: what exists in the cloud right now, and what would break if we ran a destroy or a rolling patch tonight?</p>
<p>In this article, you will get a decision framework for Terraform versus Ansible, see the anti-patterns that fail in production, and walk through a Terraform-to-Ansible handoff you can adapt to your own cloud layout. The code examples use AWS; the same pattern applies to Azure, GCP, or any provider with a Terraform provider and SSH-reachable hosts.</p>
<aside><p><strong>What you need:</strong> Terraform CLI 1.9+, Ansible 2.16+ (<code>ansible-core</code> 2.16+), credentials for your cloud provider (the examples use AWS EC2/VPC), and basic familiarity with HCL and YAML playbooks.</p></aside>
<h2>Why teams pick the wrong tool</h2>
<p>Teams default to the tool the first engineer knew. That is how Ansible ends up provisioning networks and Terraform ends up installing Nginx inside <code>terraform apply</code>. The symptom table below maps what you see in the repo to what a clean boundary looks like.</p>
<table><thead><tr><th>Symptom</th><th>Likely cause</th><th>What good looks like</th></tr></thead><tbody><tr><td><code>ansible-playbook</code> creates dozens of cloud resources</td><td>Ansible is doing Day 0 provisioning without state</td><td>Terraform owns the cloud API; state lives in a remote backend with locking</td></tr><tr><td><code>terraform apply</code> runs shell scripts on every instance change</td><td>Terraform provisioners are doing Day 1 config</td><td>Ansible or cloud-init configures software; Terraform stops at the instance profile</td></tr><tr><td>Nobody can safely tear down staging</td><td>Resources split across tools with no single source of truth</td><td><code>terraform destroy</code> removes the cloud layer; Ansible has no orphaned provisioner modules</td></tr><tr><td>Patching requires replacing instances</td><td>OS changes were encoded in Terraform user-data only</td><td>Ansible handles Day 2 mutable updates in place</td></tr></tbody></table>
<p>The cost is not theoretical. Wrong-tool choices show up as failed destroys, surprise <code>terraform plan</code> diffs that want to recreate instances, and playbooks that cannot detect drift because there is no authoritative inventory of what Ansible itself provisioned.</p>
<h2>How Terraform and Ansible differ</h2>
<p>Both are Infrastructure as Code (IaC) tools. They solve different problems in the lifecycle.</p>
<table><thead><tr><th>Dimension</th><th>Terraform</th><th>Ansible</th></tr></thead><tbody><tr><td>Primary job</td><td>Provision and manage cloud resources via provider APIs</td><td>Configure software and OS state on reachable hosts</td></tr><tr><td>Language</td><td>HCL (declarative desired state)</td><td>YAML playbooks (task sequences, idempotent modules)</td></tr><tr><td>State</td><td>Persistent state file tracks resource IDs and attributes</td><td>No cloud inventory state; relies on inventory and facts</td></tr><tr><td>Execution</td><td>Plan → apply against APIs</td><td>Push over SSH/WinRM (or pull via ansible-pull)</td></tr><tr><td>Agent on target</td><td>None</td><td>None (agentless)</td></tr><tr><td>Strength</td><td>Create/update/destroy networks, compute, databases, IAM, load balancers, DNS</td><td>Packages, configs, services, users, rolling deploys, patching</td></tr><tr><td>Weak at</td><td>Mutable in-guest software state, fleet orchestration</td><td>Complex cloud dependency graphs, clean teardown at scale</td></tr></tbody></table>
<p><strong>Day 0, Day 1, Day 2</strong> is the useful framing for infrastructure lifecycle phases:</p>
<table><thead><tr><th>Phase</th><th>Examples</th><th>Right tool</th></tr></thead><tbody><tr><td>Day 0</td><td>VPC/VNet, subnets, security groups, VMs, managed databases, IAM roles, load balancers</td><td>Terraform</td></tr><tr><td>Day 1</td><td>OS hardening, install Docker, deploy app v1, create app user</td><td>Ansible, cloud-init, or CI/CD</td></tr><tr><td>Day 2+</td><td>Security patches, config drift fixes, rolling app updates</td><td>Ansible (+ specialized tools at scale)</td></tr></tbody></table>
<p>Terraform declares <strong>what the cloud should look like</strong>. Ansible declares <strong>what should be true on each machine</strong>. When those boundaries blur, operational pain follows.</p>
<p><strong>Containers and Kubernetes change the boundary.</strong> If workloads run in Kubernetes, Terraform typically provisions the cluster, node pools, and cloud IAM. Application state belongs in Helm charts or a GitOps controller (Argo CD, Flux), not Ansible playbooks. For Docker Compose on a single host, Ansible may still install Docker and drop compose files. For a fully GitOps-managed cluster, you may skip Ansible entirely on the workload layer.</p>
<h2>What happens if you use only one tool?</h2>
<p>Teams often commit to one tool to reduce toolchain sprawl. That works until the workload crosses layer boundaries.</p>
<ol>
<li>
<p><strong>Terraform only:</strong> Sensible for serverless stacks, managed databases, and Kubernetes platforms where GitOps handles workloads. You hit walls on fleet-wide patching, rolling application deploys without instance replacement, and in-guest config drift. Every Nginx config change becomes a user-data edit and an instance recreate. Production teams end up bolting on provisioners or external scripts, which recreates the hybrid problem without Ansible&#x27;s ergonomics.</p>
</li>
<li>
<p><strong>Ansible only:</strong> Sensible for brownfield fleets, network device configuration, and patch compliance on existing hosts. You hit walls on multi-resource cloud layouts with clean destroy, dependency graphs across network/subnet/database resources, and drift detection at the API layer. A playbook that created 40 resources cannot answer &quot;what exists?&quot; the way <code>terraform plan</code> can. Production teams end up maintaining inventory spreadsheets alongside playbooks.</p>
</li>
</ol>
<h2>The wrong patterns teams ship first</h2>
<p>Before the handoff pattern, here is what the broken version looks like.</p>
<ol>
<li>
<p><strong>Using Ansible as your cloud provisioner</strong></p>
<p>Teams already fluent in YAML reach for cloud collection tasks (<code>amazon.aws</code>, <code>azure.azcollection</code>, <code>google.cloud</code>). This works for a handful of resources. It falls apart when you need destroy semantics, dependency ordering, or drift visibility.</p>
<p>The playbook below provisions a small AWS stack entirely through Ansible. It is an anti-pattern for illustration: do not use this for production cloud stacks.</p>
<pre><code class="language-yaml">- name: Provision app stack with Ansible only
  hosts: localhost
  connection: local
  tasks:
    - name: Create VPC
      amazon.aws.ec2_vpc_net:
        name: app-vpc
        cidr_block: 10.0.0.0/16
        region: us-east-1

    - name: Launch application instance
      amazon.aws.ec2_instance:
        name: app-server-01
        instance_type: t3.small
        image_id: ami-0c55b159cbfafe1f0
        vpc_subnet_id: &quot;{{ subnet_id }}&quot;
        region: us-east-1
</code></pre>
<p>There is no equivalent to <code>terraform plan</code> showing what will change. There is no first-class <code>destroy</code> that walks dependencies in reverse. Re-running the playbook after manual console edits does not reliably converge the account back to a declared desired state. For multi-resource cloud layouts, that is the wrong tool.</p>
</li>
<li>
<p><strong>Using Terraform provisioners for application configuration</strong></p>
<p>The mirror mistake is configuring software inside <code>terraform apply</code>. This AWS example ties Nginx installation to the EC2 resource lifecycle:</p>
<pre><code class="language-hcl">resource &quot;aws_instance&quot; &quot;app&quot; {
  ami           = var.ami_id
  instance_type = &quot;t3.small&quot;
  subnet_id     = aws_subnet.app.id

  provisioner &quot;remote-exec&quot; {
    inline = [
      &quot;sudo apt-get update&quot;,
      &quot;sudo apt-get install -y nginx&quot;,
      &quot;sudo systemctl enable nginx&quot;,
    ]
  }
}
</code></pre>
<p>Every instance replacement re-runs installation. Application deploys become Terraform operations. Secrets and scripts end up in state or in-line in HCL. Rolling updates across a fleet do not belong here. Terraform should output instance IDs and connection info; configuration belongs elsewhere.</p>
</li>
</ol>
<h2>A practical decision framework</h2>
<p>Answer these in order. First match wins.</p>
<ol>
<li><strong>Are you creating or destroying cloud resources through a provider API?</strong> (VMs, VPCs/VNets, managed databases, object storage, Kubernetes CRDs via a provider) → <strong>Terraform</strong></li>
<li><strong>Are you changing software, files, packages, or services on hosts that already exist?</strong> → <strong>Ansible</strong></li>
<li><strong>Is it bootstrap-only on first boot?</strong> (hostname, admin user, install agent) → <strong>cloud-init / user-data</strong>, optionally a short Ansible play triggered once</li>
<li><strong>Is it an application artifact?</strong> (container image, serverless zip, static build) → <strong>CI/CD pipeline</strong>, not Terraform or Ansible long term</li>
<li><strong>Is everything running on Kubernetes with GitOps?</strong> → Terraform for cluster and cloud IAM; <strong>Helm/Argo CD</strong> for workloads instead of Ansible</li>
</ol>
<table><thead><tr><th>You are automating</th><th>Use</th></tr></thead><tbody><tr><td>Object storage + CDN distribution</td><td>Terraform</td></tr><tr><td>CIS hardening on 200 Linux VMs</td><td>Ansible</td></tr><tr><td>Managed database + subnet group + parameter group</td><td>Terraform</td></tr><tr><td>Rolling Node.js deploy with health checks</td><td>Ansible or CI/CD</td></tr><tr><td>IAM role for GitHub Actions OIDC</td><td>Terraform (<a href="https://reclear.io/blog/oidc-for-cicd-eliminate-long-lived-cloud-keys">see OIDC guide</a>)</td></tr><tr><td>Quarterly OpenSSL package updates</td><td>Ansible</td></tr><tr><td>Multi-region DNS failover routing</td><td>Terraform (<a href="https://reclear.io/blog/how-to-automate-multi-region-failover-aws-lambda-route53">see failover guide</a>)</td></tr></tbody></table>
<p><img src="https://reclear.io/blog/terraform-vs-ansible-when-to-use-which/diagram/decision-framework-branded.webp" alt="Decision flowchart: cloud API tasks go to Terraform, host configuration goes to Ansible, and full VM stacks use Terraform first then Ansible for Day 1 configuration"/></p>
<aside><p>If you need both infrastructure <strong>and</strong> configuration for bare VMs, default to <strong>Terraform first, Ansible second</strong>. Provision stable connection endpoints, then configure. Do not run them in parallel against the same concern.</p></aside>
<h2>How to hand off from Terraform to Ansible</h2>
<p>The production pattern is sequential: Terraform creates infrastructure and <strong>exports</strong> what Ansible needs. Ansible never hard-codes IP addresses that change on rebuild.</p>
<p>Wire the handoff in this order:</p>
<p><strong>Step 1: Tag instances and export metadata from Terraform</strong></p>
<p>Tag compute instances so Ansible can discover them without parsing state by hand. The resource block below adds standard tags to an AWS EC2 instance; use your provider&#x27;s equivalent tag mechanism for Azure or GCP.</p>
<pre><code class="language-hcl">resource &quot;aws_instance&quot; &quot;app&quot; {
  ami           = var.ami_id
  instance_type = &quot;t3.small&quot;
  subnet_id     = aws_subnet.app.id

  tags = {
    Role        = &quot;app&quot;
    Environment = var.environment
    ManagedBy   = &quot;terraform&quot;
  }
}
</code></pre>
<p>Export instance IDs and connection metadata as Terraform outputs so CI pipelines and operators can verify the handoff without digging through state files.</p>
<pre><code class="language-hcl">output &quot;app_instance_ids&quot; {
  description = &quot;EC2 instance IDs for Ansible dynamic inventory&quot;
  value       = [aws_instance.app.id]
}

output &quot;app_private_ips&quot; {
  description = &quot;Private IPs for SSH bastion access&quot;
  value       = [aws_instance.app.private_ip]
}

output &quot;ansible_ssh_user&quot; {
  value = &quot;ubuntu&quot;
}
</code></pre>
<p>Store state remotely (S3 + DynamoDB lock, Terraform Cloud, Azure Blob, GCS, or equivalent) so CI and operators share one source of truth.</p>
<p><strong>Step 2: Point Ansible at your instances</strong></p>
<p>Two inventory patterns are common in production. Pick one; do not maintain both for the same hosts.</p>
<p><strong>Option A: Cloud provider dynamic inventory (most common)</strong></p>
<p>The cloud provider&#x27;s dynamic inventory plugin discovers instances by tags or labels. On AWS, the <code>amazon.aws.aws_ec2</code> plugin is the standard choice when Terraform owns provisioning and Ansible only needs to find running hosts.</p>
<p>The inventory file below filters AWS instances by <code>Role</code> and <code>Environment</code> tags and builds host groups Ansible can target directly.</p>
<pre><code class="language-yaml">plugin: amazon.aws.aws_ec2
regions:
  - us-east-1
filters:
  tag:Role: app
  tag:Environment: production
hostnames:
  - tag:Name
  - private-ip-address
compose:
  ansible_host: private_ip_address
keyed_groups:
  - key: tags
    prefix: tag
</code></pre>
<p><strong>Option B: Terraform state inventory</strong></p>
<p>The <code>cloud.terraform</code> inventory plugin reads Terraform state directly. Use this when your pipeline already downloads state after <code>terraform apply</code> and you want inventory derived from the same source of truth.</p>
<p>Point the plugin at your local or pulled state file with a minimal inventory configuration like this:</p>
<pre><code class="language-yaml">plugin: cloud.terraform.terraform_provider
terraform_state:
  path: ./terraform.tfstate
</code></pre>
<p>For remote state, point <code>path</code> at the pulled state file your pipeline downloads after <code>terraform apply</code>, or use the plugin&#x27;s supported remote backend configuration from the <a href="https://docs.ansible.com/ansible/latest/collections/cloud/terraform/terraform_provider_inventory.html">collection documentation</a>.</p>
<p><strong>Step 3: Configure software with Ansible</strong></p>
<p>Once inventory is wired, Ansible should only manage software and configuration on hosts Terraform already provisioned. The playbook below installs Nginx and deploys an application config template:</p>
<pre><code class="language-yaml">- name: Configure application servers
  hosts: tag_Role_app
  become: true
  tasks:
    - name: Install Nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true

    - name: Deploy application config
      ansible.builtin.template:
        src: templates/app.conf.j2
        dest: /etc/nginx/conf.d/app.conf
        mode: &quot;0644&quot;
      notify: Reload nginx

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded
</code></pre>
<p>When using Option B (<code>cloud.terraform</code>), target the host group the plugin generates from your resource address (for example, <code>aws_instance.app</code>) instead of <code>tag_Role_app</code>.</p>
<p>Run Terraform apply first, then point Ansible at the dynamic inventory. The CI sequence below installs the required collections and runs the configuration playbook:</p>
<pre><code class="language-bash">terraform init &amp;&amp; terraform apply -auto-approve
ansible-galaxy collection install amazon.aws cloud.terraform
ansible-playbook -i inventory.aws_ec2.yml playbooks/configure-app.yml
</code></pre>
<p>Terraform owns the compute instance lifecycle. Ansible owns Nginx and app config. Destroy path stays clean: remove Ansible from the picture, <code>terraform destroy</code> deletes the cloud layer.</p>
<h2>How to verify the split is working</h2>
<p>Confirm the boundary with three checks after your first integrated apply.</p>
<ol>
<li><strong>Terraform plan is empty of shell provisioners.</strong> Run <code>terraform plan</code> after Ansible configures software. You should see no changes unless infrastructure inputs changed. If Nginx edits appear in plan, configuration leaked into Terraform.</li>
<li><strong>Ansible <code>--check</code> converges without cloud creation tasks.</strong> Run <code>ansible-playbook --check</code> against the dynamic inventory. Tasks should report changes only on packages, files, or services. No cloud provisioning modules should run in steady-state configuration playbooks.</li>
<li><strong>Destroy is predictable.</strong> In a sandbox account, <code>terraform destroy</code> removes the cloud stack. Ansible should not leave orphaned resources you cannot reconcile because it was the provisioner of record.</li>
</ol>
<table><thead><tr><th>Check</th><th>Pass signal</th><th>Fail signal</th></tr></thead><tbody><tr><td>Plan after config change</td><td><code>No changes</code> from Terraform</td><td>Instance wants replacement because user-data changed</td></tr><tr><td>Ansible check mode</td><td>Task skips or shows service/file diffs only</td><td>Playbook tries to create VPC or VM</td></tr><tr><td>Staging destroy</td><td>All Terraform-managed IDs gone</td><td>Orphan instances remain in console</td></tr></tbody></table>
<h2>When this approach breaks down</h2>
<p>The Terraform-plus-Ansible split is not universal.</p>
<ul>
<li><strong>Fully managed services:</strong> Serverless functions, container platforms without SSH access, and managed databases may need little or no Ansible. Pushing Ansible into those paths adds SSH assumptions that do not exist.</li>
<li><strong>Immutable infrastructure:</strong> If you bake VM images or container images with Packer and redeploy on every change, Ansible Day 2 plays shrink. Terraform (or Kubernetes) handles rollouts; patching moves into image rebuilds.</li>
<li><strong>Kubernetes-native platforms:</strong> GitOps controllers apply manifests continuously. Ansible OS hardening on nodes may still exist, but application state belongs in Helm charts, not playbooks.</li>
<li><strong>Network appliances and SaaS APIs:</strong> Ansible configures some firewalls and switches via API modules. Terraform manages others via providers. Pick per vendor capability, not habit.</li>
<li><strong>Small single-server stacks:</strong> A one-box staging environment might use cloud-init only. Adding Ansible and Terraform for one small VM is overhead without payoff.</li>
</ul>
<p>Container-heavy teams should also read <a href="https://reclear.io/blog/docker-ports-vs-expose">Docker <code>ports</code> vs <code>expose</code></a> for network boundary decisions that often sit next to this toolchain split.</p>
<p>For authoritative references, see the <a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs">Terraform AWS provider documentation</a>, the <a href="https://docs.ansible.com/ansible/latest/scenario_guides/guide_aws.html">Ansible AWS guide</a>, and HashiCorp&#x27;s <a href="https://developer.hashicorp.com/terraform/language/resources/provisioners/syntax">provisioner guidance</a> (provisioners are a last resort).</p>]]></content:encoded>
      <pubDate>Thu, 18 Jun 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Ilyas Rufai)</author>
      <category>DevOps</category>
      <category>Terraform</category>
      <category>Ansible</category>
      <category>InfrastructureasCode</category>
      <enclosure url="https://reclear.io/blog/terraform-vs-ansible-when-to-use-which/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>How to Eliminate Long-Lived Cloud Keys in CI/CD Using OIDC</title>
      <link>https://reclear.io/blog/oidc-for-cicd-eliminate-long-lived-cloud-keys</link>
      <guid isPermaLink="true">https://reclear.io/blog/oidc-for-cicd-eliminate-long-lived-cloud-keys</guid>
      <description>Learn how to replace static AWS access keys in GitHub Actions with short-lived OIDC credentials, configure strict IAM trust policies, and secure multi-environment deployment pipelines.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/oidc-for-cicd-eliminate-long-lived-cloud-keys/cover/cover.webp" alt="How to Eliminate Long-Lived Cloud Keys in CI/CD Using OIDC" /></p><aside><ul>
<li>Long-lived <code>AKIA</code> keys in GitHub Secrets are standing privilege: every workflow that reads the secret inherits cloud access until someone rotates keys.</li>
<li>OIDC federation issues <strong>short-lived</strong> STS credentials bound to repository, branch, or environment claims in the JWT <code>sub</code> and <code>aud</code> fields.</li>
<li>Configure the GitHub OIDC provider once per AWS account, scope an IAM role trust policy tightly, and set <code>permissions: id-token: write</code> in the workflow.</li>
<li>Use separate roles for staging and production; bind production to GitHub Environments with required reviewers.</li>
<li>Keep fork PR workflows off deploy roles; split read-only CI roles from deploy roles.</li>
</ul></aside>
<p>If your deployment pipeline authenticates to AWS with access keys stored in GitHub Secrets, you are carrying credentials that outlive every job. Those keys are often over-permissioned, rotated manually (if ever), and visible to every workflow that can read the secret. When a workflow is compromised through a poisoned dependency, a typosquatted action, or a fork PR exfiltration trick, the blast radius is your cloud account until someone notices.</p>
<p>OpenID Connect (OIDC) federation changes the credential shape. CI jobs receive <strong>short-lived tokens</strong> minted for a <strong>specific repository, branch, and environment</strong>, bound to an IAM role with least privilege. No static keys in GitHub. No shared deploy user in a password manager.</p>
<p>This guide is for platform and DevSecOps engineers running GitHub Actions against AWS. The same pattern generalizes to <a href="https://cloud.google.com/iam/docs/workload-identity-federation">GCP Workload Identity</a> and <a href="https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect">Azure Federated Credentials</a>. You will configure an IAM OIDC identity provider, a trust-scoped IAM role, and a deploy workflow that assumes the role without secrets.</p>
<h2>What do you need before you start?</h2>
<ul>
<li>AWS account access with permission to create IAM OIDC providers and roles (IAM admin or equivalent).</li>
<li>GitHub repository admin access to update workflows and delete legacy secrets.</li>
<li>Familiarity with IAM trust policies and GitHub Actions <code>permissions</code> blocks.</li>
<li>A target deploy surface (for example S3, EKS, or Lambda) so you can scope the role permissions policy.</li>
</ul>
<h2>Why do long-lived CI keys fail in production?</h2>
<p>Teams adopt access keys because they work on day one. The costs show up later:</p>
<table><thead><tr><th>Symptom</th><th>Likely cause</th><th>What good looks like</th></tr></thead><tbody><tr><td>Keys in five repos &quot;for now&quot;</td><td>Copy-paste deploy user</td><td>One role per repo or environment, OIDC-bound</td></tr><tr><td>Rotation ticket keeps slipping</td><td>Manual process, fear of outage</td><td>No keys to rotate; tokens expire in minutes</td></tr><tr><td>Auditor asks who can assume <code>PowerUser</code></td><td>Shared IAM user</td><td>CloudTrail shows <code>AssumeRoleWithWebIdentity</code> from a known repo</td></tr><tr><td>Fork PR suddenly wants AWS creds</td><td><code>pull_request</code> workflows with secrets</td><td>OIDC <code>sub</code> claim scoped; no secrets on untrusted forks</td></tr></tbody></table>
<p>Static keys conflict with zero-trust thinking: the CI system is not a person and should not carry a permanent password. <a href="https://reclear.io/blog/implementing-zero-trust-architecture-in-saas">Implementing zero-trust in SaaS</a> frames identity around continuous verification; OIDC applies that verification to the <strong>deploy path</strong>, not only the VPN you retired.</p>
<h2>What does the naive GitHub Actions deploy workflow look like?</h2>
<p>The first workflow most teams ship works in staging and becomes a liability in production. It stores an IAM user&#x27;s access keys in GitHub Secrets and injects them into every deploy job:</p>
<p><strong>Label:</strong> Anti-pattern for illustration (do not use in production).</p>
<pre><code class="language-yaml">name: Deploy to AWS

on:
  push:
    branches: [main]
  pull_request:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Sync static assets
        run: aws s3 sync ./dist s3://my-app-artifacts/production/ --delete
</code></pre>
<p>Three problems stack on top of each other:</p>
<table><thead><tr><th>Problem</th><th>Why it hurts</th></tr></thead><tbody><tr><td>Long-lived keys in Secrets</td><td>Every workflow with secret access inherits the same cloud identity until someone rotates keys</td></tr><tr><td>Shared IAM user across repos</td><td>One copied secret in five repositories means five blast radii tied to one <code>AKIA</code> user</td></tr><tr><td><code>pull_request</code> trigger with cloud creds</td><td>Fork PRs can exfiltrate secrets through malicious workflow changes unless you block untrusted runs</td></tr></tbody></table>
<p>The deploy step itself is fine. The credential model is the failure: permanent keys, broad scope, and no binding to a specific repository, branch, or environment. OIDC replaces that model without changing what the deploy step runs.</p>
<h2>How does OIDC authentication work in GitHub Actions?</h2>
<ol>
<li>GitHub Actions requests an <strong>OIDC JWT</strong> from GitHub (the <code>ACTIONS_ID_TOKEN_REQUEST_TOKEN</code> flow, handled by <code>aws-actions/configure-aws-credentials</code> when configured).</li>
<li>The AWS <strong>IAM OIDC identity provider</strong> trusts GitHub&#x27;s issuer (<code>token.actions.githubusercontent.com</code>).</li>
<li>Your <strong>IAM role trust policy</strong> checks JWT claims (<code>sub</code>, <code>aud</code>, <code>repository</code>, <code>ref</code>) before allowing <code>sts:AssumeRoleWithWebIdentity</code>.</li>
<li>The workflow receives temporary <code>AWS_ACCESS_KEY_ID</code>, <code>AWS_SECRET_ACCESS_KEY</code>, and <code>AWS_SESSION_TOKEN</code> values valid for the job only.</li>
</ol>
<p>The trust boundary is claim-based: only workflows matching your filters can assume the role. Most production incidents come from a <strong>too loose</strong> <code>StringLike</code> on <code>sub</code>, not from OIDC being inherently weak.</p>
<p><img src="https://reclear.io/blog/oidc-for-cicd-eliminate-long-lived-cloud-keys/diagram/oidc-federation-flow-branded.webp" alt="OIDC federation flow: GitHub Workflow sends an OIDC JWT to the IAM OIDC Provider, which validates trust policy claims before AssumeRoleWithWebIdentity issues a Deploy Role session that reaches S3 or EKS"/></p>
<table><thead><tr><th>Dimension</th><th>Long-lived access keys</th><th>OIDC-federated role</th></tr></thead><tbody><tr><td>Lifetime</td><td>Until rotated or deleted</td><td>Minutes (STS session)</td></tr><tr><td>Storage</td><td>GitHub Secret, often duplicated</td><td>No cloud secret in GitHub</td></tr><tr><td>Scope</td><td>Whatever the IAM user has</td><td>Per role, per repo, branch, or environment</td></tr><tr><td>Audit</td><td><code>AccessKeyId</code> in CloudTrail</td><td><code>AssumeRoleWithWebIdentity</code> plus <code>sub</code> claim</td></tr><tr><td>Compromise recovery</td><td>Rotate all copies, hunt repos</td><td>Tighten trust policy; sessions expire</td></tr><tr><td>Setup cost</td><td>Low on day one</td><td>One-time IAM and workflow change</td></tr></tbody></table>
<h2>How do you configure OIDC federation between GitHub Actions and AWS?</h2>
<div><h3>Add the GitHub OIDC provider in AWS</h3><p>Run once per AWS account (console: <strong>IAM → Identity providers → Add provider → OpenID Connect</strong>):</p><table><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody><tr><td>Provider URL</td><td><code>https://token.actions.githubusercontent.com</code></td></tr><tr><td>Audience</td><td><code>sts.amazonaws.com</code></td></tr></tbody></table><p><strong>Label:</strong> Production-ready CLI example (replace thumbprint if AWS documentation requires an update).</p><pre><code class="language-bash">aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03fa02197ae0c8ae26c8b69dd77
</code></pre><aside><p>Thumbprints change rarely. If provider creation fails, verify the current thumbprint in the <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc_verify-thumbprint.html">AWS IAM OIDC thumbprint guide</a>.</p></aside><p><strong>Verify:</strong> <code>aws iam list-open-id-connect-providers</code> shows the GitHub issuer ARN.</p><h3>Create an IAM role with a tight trust policy</h3><p>Replace <code>ORG</code>, <code>REPO</code>, and the account ID. This example allows <strong>only</strong> the <code>main</code> branch of one repository:</p><p><strong>Label:</strong> Simplified trust policy for illustration (replace placeholders before production use).</p><pre><code class="language-json">{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Principal&quot;: {
        &quot;Federated&quot;: &quot;arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com&quot;
      },
      &quot;Action&quot;: &quot;sts:AssumeRoleWithWebIdentity&quot;,
      &quot;Condition&quot;: {
        &quot;StringEquals&quot;: {
          &quot;token.actions.githubusercontent.com:aud&quot;: &quot;sts.amazonaws.com&quot;,
          &quot;token.actions.githubusercontent.com:sub&quot;: &quot;repo:ORG/REPO:ref:refs/heads/main&quot;
        }
      }
    }
  ]
}
</code></pre><p>Attach a least-privilege permissions policy. Example for S3 deploy to one bucket prefix:</p><pre><code class="language-json">{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Sid&quot;: &quot;DeployArtifacts&quot;,
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Action&quot;: [&quot;s3:PutObject&quot;, &quot;s3:GetObject&quot;, &quot;s3:ListBucket&quot;, &quot;s3:DeleteObject&quot;],
      &quot;Resource&quot;: [
        &quot;arn:aws:s3:::my-app-artifacts&quot;,
        &quot;arn:aws:s3:::my-app-artifacts/production/*&quot;
      ]
    }
  ]
}
</code></pre><p>For multiple environments, create <code>deploy-staging</code> and <code>deploy-production</code> roles. Scope <code>sub</code> with <code>ref:refs/heads/main</code> versus <code>environment:production</code> (see the next step).</p><p><strong>Verify:</strong> In IAM → Roles → Trust relationships, the federated principal matches your repository slug exactly (case-sensitive).</p><h3>Wire GitHub Actions to assume the role without secrets</h3><p><strong>Label:</strong> Production-ready workflow pattern (update role ARN, region, and S3 paths).</p><pre><code class="language-yaml">name: Deploy to AWS

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy-production
          aws-region: us-east-1

      - name: Sync static assets
        run: aws s3 sync ./dist s3://my-app-artifacts/production/ --delete
</code></pre><p>Delete legacy secrets <code>AWS_ACCESS_KEY_ID</code> and <code>AWS_SECRET_ACCESS_KEY</code>. If anything still works after deletion, you found another copy in the org or a reusable workflow.</p><p><strong>Verify:</strong> The workflow log shows a successful role assumption. In CloudTrail, filter <code>EventName = AssumeRoleWithWebIdentity</code> and confirm <code>userIdentity.sessionContext.sessionIssuer</code> points at your role.</p><h3>Bind production deploys to GitHub Environments</h3><p>For production, align human approval with claim scoping. Trust policy fragment:</p><pre><code class="language-json">&quot;StringEquals&quot;: {
  &quot;token.actions.githubusercontent.com:sub&quot;: &quot;repo:ORG/REPO:environment:production&quot;
}
</code></pre><p>Workflow:</p><pre><code class="language-yaml">jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      # ... same configure-aws-credentials step with the production role ARN
</code></pre><p>Required reviewers on the <code>production</code> environment keep identity and process control aligned.</p></div>
<h2>What errors appear when OIDC trust is misconfigured?</h2>
<table><thead><tr><th>Error or symptom</th><th>Fix</th></tr></thead><tbody><tr><td><code>Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentity</code></td><td>Trust policy <code>sub</code> or <code>aud</code> mismatch; confirm repository name case and branch ref</td></tr><tr><td><code>Credentials could not be loaded, please check your action inputs</code></td><td>Missing <code>role-to-assume</code> or <code>id-token: write</code> not set</td></tr><tr><td><code>Access Denied</code> on <code>s3:PutObject</code> after successful assume</td><td>Permissions policy on the role, not OIDC; trust worked</td></tr><tr><td>Works on <code>main</code> but not on a feature branch</td><td>Trust policy only allows <code>refs/heads/main</code>; add a staging role or environment claim</td></tr><tr><td>Intermittent failures on self-hosted runners</td><td>Runner must reach <code>sts.amazonaws.com</code> and the GitHub OIDC endpoint; corporate SSL inspection can break JWT exchange</td></tr></tbody></table>
<p><strong>Missing <code>id-token: write</code>:</strong> <code>configure-aws-credentials</code> cannot mint a JWT. Set the permission on the workflow or org default.</p>
<p><strong><code>sub</code> mismatch from typos:</strong> <code>repo:MyOrg/my-app</code> versus <code>repo:myorg/My-App</code> fails closed. Use CloudTrail <code>AssumeRoleWithWebIdentity</code> events to compare the <code>sub</code> claim with your trust policy. Do not print OIDC tokens in workflow logs.</p>
<p><strong>Fork PR workflows:</strong> Do not grant OIDC-capable production roles to <code>pull_request</code> from forks unless you fully understand <code>pull_request_target</code> and claim spoofing. Prefer OIDC on <code>push</code> to protected branches or <code>workflow_dispatch</code>.</p>
<p><strong>Over-broad <code>StringLike</code>:</strong> <code>repo:ORG/*</code> lets any repository in the org assume the role. Scope to one repository; split roles per service.</p>
<p><strong>Same role for build and deploy:</strong> Unit test jobs should not assume deploy roles. Use <code>ci-readonly</code> (ECR pull, artifact read) versus <code>deploy-production</code>.</p>
<p><strong>CloudTrail spot-check:</strong> Confirm <code>userIdentity.sessionContext.webIdFederationData.federatedProvider</code> shows GitHub. If you still see <code>AKIA</code> users from GitHub Actions IP ranges, a workflow still uses keys.</p>
<h3>How do you deploy to EKS with an OIDC role?</h3>
<p>OIDC roles often need <code>eks:DescribeCluster</code> plus RBAC mapped to the role:</p>
<pre><code class="language-json">{
  &quot;Effect&quot;: &quot;Allow&quot;,
  &quot;Action&quot;: [&quot;eks:DescribeCluster&quot;],
  &quot;Resource&quot;: &quot;arn:aws:eks:us-east-1:123456789012:cluster/prod&quot;
}
</code></pre>
<p>Map the role into the <code>aws-auth</code> ConfigMap or EKS access entries once. Avoid embedding cluster admin in the GitHub role; prefer namespace-scoped RBAC and service account impersonation where possible.</p>
<h2>When should you not rely on OIDC for CI/CD?</h2>
<p>OIDC is the right default for GitHub Actions to AWS, but it is not universal:</p>
<ul>
<li><strong>CI platforms without OIDC:</strong> Older or self-hosted runners that cannot request a GitHub OIDC JWT need another pattern (instance roles on self-hosted infra, or short-lived credentials from a secrets manager with tight rotation).</li>
<li><strong>Org-wide shared deploy users:</strong> If you cannot split roles per repository or environment, OIDC still helps, but loose <code>sub</code> patterns recreate the blast radius you are trying to remove.</li>
<li><strong>Untrusted fork PRs needing cloud access:</strong> OIDC should not widen trust for fork workflows. Use read-only checks without cloud credentials, or isolated sandboxes.</li>
<li><strong>Break-glass human access:</strong> Emergency admin belongs in a separate human-assumable role, not in the CI deploy role. The CI role should not escalate to account admin.</li>
<li><strong>One-shot scripts outside Actions:</strong> Local deploy scripts still need their own credential model; OIDC does not replace human MFA for console work.</li>
</ul>
<p>Framed for developers, OIDC is also <strong>deploy reliability</strong>: fewer rotation outages, clearer CloudTrail, and PR checks that do not need production keys. That is the same adoption logic as <a href="https://reclear.io/blog/api-error-handling-best-practices">structured API errors</a> and <a href="https://reclear.io/blog/webhook-handlers-best-practices-retry-patterns">idempotent webhook handlers</a>: make the secure path the easiest path.</p>
<h2>How do you roll out OIDC across an organization?</h2>
<ol>
<li>Ship a <strong>reference workflow</strong> and Terraform module (OIDC provider plus role template) in a platform repository.</li>
<li>Require OIDC for <strong>new</strong> repositories via org policy; grandfather existing repos with a sunset date for <code>AKIA</code> keys.</li>
<li>Run a monthly IAM credential report and scan org secrets for <code>AWS_ACCESS_KEY_ID</code>.</li>
<li>Pair migration with secret scanning on PRs so keys do not return while teams move.</li>
</ol>
<h2>How do you harden an OIDC deploy pipeline?</h2>
<ul class="contains-task-list">
<li class="task-list-item"><input type="checkbox" disabled=""/> No <code>AKIA*</code> access keys in GitHub Secrets for CI</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> Trust policy uses <code>StringEquals</code> on <code>aud</code> and narrow <code>StringLike</code> on <code>sub</code></li>
<li class="task-list-item"><input type="checkbox" disabled=""/> Separate IAM roles per environment; production uses GitHub Environment protection</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> CloudTrail alerts on <code>AssumeRoleWithWebIdentity</code> from unexpected repositories</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> Pin third-party actions to full commit SHAs for deploy workflows</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> <code>permissions:</code> defaults to minimum; <code>id-token: write</code> only where needed</li>
<li class="task-list-item"><input type="checkbox" disabled=""/> Document break-glass human role separately; the CI role cannot escalate to admin</li>
</ul>
<p>OIDC does not replace scanning, policy-as-code, or runtime detection. It removes a <strong>durable credential class</strong> that attackers hunt first. Combined with short-lived tokens, you shrink mean time to containment when a pipeline is abused: stolen session credentials expire with the job.</p>
<p>For authoritative setup steps, see <a href="https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services">GitHub: Configuring OpenID Connect in AWS</a>, <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html">AWS IAM OIDC identity providers</a>, and <a href="https://csrc.nist.gov/publications/detail/sp/800-207/final">NIST SP 800-207</a> for zero-trust context.</p>]]></content:encoded>
      <pubDate>Sat, 06 Jun 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Ilyas Rufai)</author>
      <category>Tutorials</category>
      <category>DevSecOps</category>
      <category>GitHubActions</category>
      <category>OIDC</category>
      <enclosure url="https://reclear.io/blog/oidc-for-cicd-eliminate-long-lived-cloud-keys/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Bitwise Operators for Boolean Flags in JavaScript</title>
      <link>https://reclear.io/blog/bitmask-operations-the-math-you-skipped</link>
      <guid isPermaLink="true">https://reclear.io/blog/bitmask-operations-the-math-you-skipped</guid>
      <description>Learn how bitwise AND, OR, XOR, and NOT pack multiple boolean flags into a single integer, why the check is 1.5x faster than array lookup, and where the pattern appears in production systems.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/bitmask-operations-the-math-you-skipped/cover/cover.webp" alt="Bitwise Operators for Boolean Flags in JavaScript" /></p><aside><ul>
<li>A bitmask packs multiple yes/no flags into a single integer. Each flag occupies one bit position; the number <code>5</code> (binary <code>0101</code>) means positions 0 and 2 are on, positions 1 and 3 are off.</li>
<li>Four operators cover all flag manipulation: OR sets flags, AND checks them, AND NOT clears them, XOR toggles them. Each operation is a single CPU instruction.</li>
<li>A bitwise AND check is 1.5x faster than <code>.includes()</code> on an array and uses 46% less memory than storing the same flags as boolean object properties.</li>
<li>The pattern is not new. Unix <code>chmod 755</code>, TCP header flags, and game engine entity state all use it. For the full permission system implementation, see <a href="https://reclear.io/blog/bitmask-permissions-role-based-access-control">Encoding RBAC as a JWT Integer Without Redis</a>.</li>
</ul></aside>
<p>Most developers have seen code like <code>user.permissions &amp; CAN_DELETE</code> and moved on without understanding what the <code>&amp;</code> does or why it is written that way. The expression is not clever syntax. It is a lookup that runs in a single CPU instruction because the entire permission set lives inside one number.</p>
<p>This article explains how binary numbers encode multiple states, how the four bitwise operators manipulate them, and where this pattern shows up in production so you can read and write it with confidence.</p>
<h2>How binary numbers work</h2>
<p>Humans count with ten digits (0 through 9). When a position runs out of digits, it carries over to the next column:</p>
<pre><code class="language-text">8, 9, 10, 11 ... 98, 99, 100
</code></pre>
<p>Computers use two digits: 0 and 1, because transistors have two states: off and on. They carry over at 2 instead of 10:</p>
<pre><code class="language-text">0, 1, 10, 11, 100, 101, 110, 111, 1000 ...
</code></pre>
<p>This is base 2, or binary. The positional rule is the same as decimal: each column is worth the base raised to that position&#x27;s power. In decimal, columns are worth 1, 10, 100. In binary, columns are worth 1, 2, 4, 8:</p>
<pre><code class="language-text">position:  3   2   1   0
value:     8   4   2   1
</code></pre>
<p>The number <code>5</code> in binary is <code>0101</code>. Position 2 is on (worth 4), position 0 is on (worth 1). The others are off:</p>
<pre><code class="language-text">position:  3   2   1   0
bit:       0   1   0   1
value:     0 + 4 + 0 + 1 = 5
</code></pre>
<h2>Using bits to represent feature flags</h2>
<p>Once you see that each bit position is an independent on/off slot, it becomes possible to use a single integer as a set of named boolean states. Assign a meaning to each position:</p>
<pre><code class="language-text">position:  3          2              1            0
meaning:   beta       notifications  analytics    dark mode
bit:       0          1              0            1
</code></pre>
<p>A user with dark mode and notifications enabled is stored as <code>0101</code>, which equals <code>5</code>. All four settings live in one integer.</p>
<p><img src="https://reclear.io/blog/bitmask-operations-the-math-you-skipped/diagram/binary-visualization.webp" alt="Bitmask visualization: the number 5 (binary 0101) decoded into four independent flag positions: Dark Mode ON, Analytics OFF, Notifications ON, Beta Access OFF"/></p>
<p>This is a bitmask: a single integer used to encode multiple boolean states, one per bit position.</p>
<h2>Packing multiple settings into one integer</h2>
<p>The conventional approach stores each flag as a named property:</p>
<pre><code class="language-typescript">const userSettings = {
  darkMode: true,
  analytics: false,
  notifications: true,
  betaAccess: false,
};
</code></pre>
<p>This is readable, but each object carries the property name strings, the values, and object overhead. Across millions of records, that adds up. Storing 100,000 of these objects uses 12.05 MB versus 6.47 MB for the equivalent bitmask integers.</p>
<p>The bitmask approach assigns each flag a power of two using the left-shift operator, which is shorthand for &quot;1 shifted left by n positions.&quot; The pattern makes each constant&#x27;s bit position explicit and prevents the typos that come from writing the powers of two by hand:</p>
<pre><code class="language-typescript">const FLAGS = {
  DARK_MODE:     1 &lt;&lt; 0,  // 0001 = 1
  ANALYTICS:     1 &lt;&lt; 1,  // 0010 = 2
  NOTIFICATIONS: 1 &lt;&lt; 2,  // 0100 = 4
  BETA_ACCESS:   1 &lt;&lt; 3,  // 1000 = 8
} as const;
</code></pre>
<p>A user with dark mode and notifications enabled combines the two flags with OR:</p>
<pre><code class="language-typescript">const userSettings = FLAGS.DARK_MODE | FLAGS.NOTIFICATIONS;
// 0001 | 0100 = 0101 = 5
</code></pre>
<p>One number. Four settings. The object with its property name strings no longer exists.</p>
<p><img src="https://reclear.io/blog/bitmask-operations-the-math-you-skipped/diagram/bitmask-packing.webp" alt="Bitmask packing: a single number (5, binary 0101) fans out to four named flag states"/></p>
<h2>The four operations</h2>
<p>Every flag manipulation maps to one of four bitwise operators. Each is a single CPU instruction.</p>
<ol>
<li><strong>Setting flags with OR</strong></li>
</ol>
<p>OR turns bits on. If either operand has a 1 in a position, the result has a 1 there. Use it to enable one or more flags at once:</p>
<pre><code class="language-typescript">let settings = 0;                             // 0000 — nothing set
settings = FLAGS.DARK_MODE | FLAGS.ANALYTICS; // 0001 | 0010 = 0011 = 3
</code></pre>
<p><img src="https://reclear.io/blog/bitmask-operations-the-math-you-skipped/diagram/or-operation.webp" alt="OR operation: DARK_MODE (0001) combined with NOTIFICATIONS (0100) via the | operator produces 0101 (5)"/></p>
<ol start="2">
<li><strong>Checking flags with AND</strong></li>
</ol>
<p>AND tests whether a specific bit is on. The result is non-zero only if both operands share a 1 in the same position. Non-zero means the flag is set; zero means it is not:</p>
<pre><code class="language-typescript">const settings = 5; // 0101

if (settings &amp; FLAGS.DARK_MODE) {
  // 0101 &amp; 0001 = 0001 — non-zero, dark mode is on
}

if (settings &amp; FLAGS.ANALYTICS) {
  // 0101 &amp; 0010 = 0000 — zero, analytics is off
}
</code></pre>
<p><img src="https://reclear.io/blog/bitmask-operations-the-math-you-skipped/diagram/and-operation.webp" alt="AND operation: checking Analytics (0010) against settings (0101) gives zero (off); checking Dark Mode (0001) gives non-zero (on)"/></p>
<ol start="3">
<li><strong>Clearing flags with AND NOT</strong></li>
</ol>
<p>To turn a specific bit off without touching any other, first use <code>~</code> to flip all bits in the flag, then AND the result against your value. The flipped mask is all 1s except the one position you want to clear:</p>
<pre><code class="language-typescript">let settings = 5; // 0101: dark mode on, notifications on

settings = settings &amp; ~FLAGS.DARK_MODE;
// ~FLAGS.DARK_MODE = ~0001 = 1110
// 0101 &amp; 1110 = 0100 = 4: dark mode off, notifications still on
</code></pre>
<p><img src="https://reclear.io/blog/bitmask-operations-the-math-you-skipped/diagram/and-not-operation.webp" alt="AND NOT operation: NOT flips DARK_MODE from 0001 to 1110; AND with settings (0101) clears that position, leaving 0100"/></p>
<ol start="4">
<li><strong>Toggling flags with XOR</strong></li>
</ol>
<p>XOR flips a bit regardless of its current state: 1 becomes 0, 0 becomes 1. It is the natural operator for toggle switches:</p>
<pre><code class="language-typescript">let settings = 5; // 0101: analytics off

settings ^= FLAGS.ANALYTICS; // 0101 ^ 0010 = 0111 = 7: analytics now on
settings ^= FLAGS.ANALYTICS; // 0111 ^ 0010 = 0101 = 5: analytics off again
</code></pre>
<h2>Why this matters at scale</h2>
<p>The performance advantage comes from how the check executes. A bitwise AND is a single instruction that the CPU evaluates in one cycle. There are no function calls, no heap allocation, no string comparisons. Checking <code>settings &amp; FLAGS.DARK_MODE</code> against checking <code>settings.includes(&quot;dark_mode&quot;)</code> on an array of string names:</p>
<table><thead><tr><th style="text-align:left">Approach</th><th style="text-align:left">Ops/second</th><th style="text-align:left">vs baseline</th></tr></thead><tbody><tr><td style="text-align:left">Array <code>.includes()</code></td><td style="text-align:left">2,093,325</td><td style="text-align:left">baseline</td></tr><tr><td style="text-align:left">Bitmask <code>&amp;</code> check</td><td style="text-align:left">3,012,325</td><td style="text-align:left"><strong>1.5x faster</strong></td></tr></tbody></table>
<p>Memory tells a similar story. Across 100,000 user objects with four flags each:</p>
<table><thead><tr><th style="text-align:left">Approach</th><th style="text-align:left">Total</th><th style="text-align:left">Per object</th></tr></thead><tbody><tr><td style="text-align:left">Multiple boolean properties</td><td style="text-align:left">12.05 MB</td><td style="text-align:left">126 bytes</td></tr><tr><td style="text-align:left">String array of flag names</td><td style="text-align:left">11.58 MB</td><td style="text-align:left">121 bytes</td></tr><tr><td style="text-align:left">Single bitmask integer</td><td style="text-align:left">6.47 MB</td><td style="text-align:left">68 bytes</td></tr></tbody></table>
<p>At 10 million users, the bitmask approach saves roughly 558 MB compared to boolean properties. A single integer assignment is also atomic at the hardware level; updating three separate boolean properties is not, so a read between writes can observe a partially updated state.</p>
<h2>Where this pattern already exists</h2>
<p>Once you know the pattern, you see it everywhere systems were built for performance.</p>
<p>Unix file permissions encode read, write, and execute for owner, group, and others as nine bits spread across three groups. Running <code>chmod 755</code> sets owner to <code>7</code> (binary <code>111</code>: all three on), group to <code>5</code> (binary <code>101</code>: read and execute on, write off), and others to <code>5</code>. Nine independent permissions, stored and checked as a single integer.</p>
<p>TCP packet headers carry a flags byte that encodes SYN, ACK, FIN, RST, PSH, and URG. When a server receives a packet, it checks <code>flags &amp; SYN</code> to detect a connection request. One byte, six independent states, billions of times per second.</p>
<p>Game engines track entity state (visible, collidable, active, paused, destroyed) as an integer per entity. The game loop checks flags for thousands of objects per frame. Allocating a boolean object for each would slow the loop down noticeably.</p>
<p>For the production implementation of this pattern in an auth permission system, see <a href="https://reclear.io/blog/bitmask-permissions-role-based-access-control">Encoding RBAC as a JWT Integer Without Redis</a>.</p>
<h2>When this breaks down</h2>
<p><strong>32-flag ceiling with bitwise operators.</strong> JavaScript&#x27;s <code>|</code>, <code>&amp;</code>, <code>~</code>, and <code>^</code> coerce operands to signed 32-bit integers, giving you 31 usable positions before the sign bit. You can extend to 53 by using regular arithmetic instead of bitwise operators, or go beyond 53 with <code>BigInt</code>:</p>
<pre><code class="language-typescript">const FLAGS = {
  FLAG_1:  1n &lt;&lt; 0n,
  FLAG_33: 1n &lt;&lt; 32n,
  FLAG_65: 1n &lt;&lt; 64n,
} as const;

const hasFlag = (value: bigint, flag: bigint): boolean =&gt; (value &amp; flag) !== 0n;
</code></pre>
<p><strong>Raw values in logs.</strong> <code>console.log(settings)</code> prints <code>5</code>, not <code>[&quot;DARK_MODE&quot;, &quot;NOTIFICATIONS&quot;]</code>. A small helper that maps set bits back to names resolves this during development. Keep it out of the hot path; it is for debugging only:</p>
<pre><code class="language-typescript">export function debugFlags(value: number): string[] {
  return Object.entries(FLAGS)
    .filter(([, flag]) =&gt; (value &amp; (flag as number)) !== 0)
    .map(([name]) =&gt; name);
}

// debugFlags(5) =&gt; [&quot;DARK_MODE&quot;, &quot;NOTIFICATIONS&quot;]
</code></pre>
<p><strong>No dynamic flags.</strong> Bit positions must be assigned at compile time. If your application lets users define their own flag types at runtime, you need a different approach: a junction table or a serialized set.</p>
<p><strong>Bit position stability.</strong> Once a permission occupies a position, that position is taken permanently. Reusing it for a different flag corrupts any stored integers and any tokens that carry the old value. Treat the flag schema like a database migration: only ever add positions, never reassign them.</p>
<p>This pattern works best when the flag set is fixed at deploy time, checks run in a hot path, and the number of flags stays under the integer ceiling. For small, infrequently-checked flag sets, the readability of named properties may outweigh the performance difference.</p>
<h2>Key takeaways</h2>
<p>A bitmask stores multiple boolean flags in one integer: OR sets bits, AND checks them, AND NOT clears them, and XOR toggles them, each in a single CPU instruction with no heap allocation. At scale, a bitmask check runs roughly 1.5x faster than an array include and uses half the memory of individual boolean properties across millions of objects. JavaScript&#x27;s bitwise operators coerce to signed 32-bit integers, giving you 31 usable positions before the sign bit; switch to BigInt arithmetic when your flag set exceeds that limit. Bit positions are permanent once assigned: treat the flag schema like a database migration and only ever add new positions, never reassign existing ones.</p>]]></content:encoded>
      <pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Wale Bashir)</author>
      <category>Tutorials</category>
      <category>Backend</category>
      <category>Nodejs</category>
      <category>API</category>
      <enclosure url="https://reclear.io/blog/bitmask-operations-the-math-you-skipped/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Encoding RBAC as a JWT Integer Without Redis</title>
      <link>https://reclear.io/blog/bitmask-permissions-role-based-access-control</link>
      <guid isPermaLink="true">https://reclear.io/blog/bitmask-permissions-role-based-access-control</guid>
      <description>Store role permissions as a single integer in your JWT payload. The permission check becomes one CPU instruction with no database call, no Redis lookup, and a token that is 72% smaller.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/bitmask-permissions-role-based-access-control/cover/cover.webp" alt="Encoding RBAC as a JWT Integer Without Redis" /></p><aside><ul>
<li>Checking permissions by querying the database on every request turns auth into a linear bottleneck: at 10,000 req/s that is 10,000 auth queries per second. Storing them as a string array in the JWT is O(n) per check and stales the moment permissions change.</li>
<li>Encoding permissions as a single integer in the JWT payload eliminates both problems. The check <code>userPerms &amp; REQUIRED_PERM</code> is one CPU instruction, O(1) regardless of how many permissions exist, and requires no external call.</li>
<li>The token carrying a bitmask is 72% smaller than one carrying a string array. One <code>INT</code> column in the database replaces a junction table: 10 million users with 5 permissions each means 10 million rows instead of 50 million.</li>
<li>The pattern pairs directly with the <a href="https://reclear.io/blog/session-tokens-vs-jwts-the-false-dichotomy">hybrid JWT/session approach</a>. The bitmask rides in the 15-minute access token; the refresh path re-signs with updated permissions from the database.</li>
</ul></aside>
<p>Permission systems tend to reach for one of two approaches: query the database on every request for the current permission set, or store permissions as a string array in the JWT and check them with <code>.includes()</code>. Both work at low traffic. Both become a problem as requests scale: the first adds a database round trip to every authenticated call, and the second keeps granting permissions from a stale token.</p>
<p>The alternative is to encode all permissions as a single integer inside the JWT. The permission check becomes a bitwise AND: one CPU instruction, no network call, no cache dependency.</p>
<p>This article covers the permission encoding, the database schema change it enables, the middleware, how it integrates with a hybrid auth setup, and the limitations you need to know before adopting it.</p>
<h2>How bitmask encoding works</h2>
<p>Each permission is assigned a unique power of two: one bit position that belongs to it and nothing else. The left-shift operator (<code>1 &lt;&lt; n</code>) is the standard way to express this, because it makes each constant&#x27;s position explicit and avoids the transcription errors that come from writing powers of two by hand:</p>
<pre><code class="language-typescript">export const PERMISSIONS = {
  CREATE_POSTS:     1 &lt;&lt; 0,  // 1
  EDIT_OWN_POSTS:   1 &lt;&lt; 1,  // 2
  EDIT_ALL_POSTS:   1 &lt;&lt; 2,  // 4
  DELETE_OWN_POSTS: 1 &lt;&lt; 3,  // 8
  DELETE_ALL_POSTS: 1 &lt;&lt; 4,  // 16
  MANAGE_USERS:     1 &lt;&lt; 5,  // 32
  VIEW_ANALYTICS:   1 &lt;&lt; 6,  // 64
  EXPORT_DATA:      1 &lt;&lt; 7,  // 128
} as const;

export type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS];

export function hasPermission(userPerms: number, required: Permission): boolean {
  return (userPerms &amp; required) !== 0;
}

export function addPermission(userPerms: number, perm: Permission): number {
  return userPerms | perm;
}

export function removePermission(userPerms: number, perm: Permission): number {
  return userPerms &amp; ~perm;
}
</code></pre>
<p>A user with <code>CREATE_POSTS</code> (1) and <code>EDIT_OWN_POSTS</code> (2) has <code>permissions = 3</code>. The check <code>3 &amp; 2</code> is non-zero, so they have <code>EDIT_OWN_POSTS</code>. The check <code>3 &amp; 4</code> is zero, so they do not have <code>EDIT_ALL_POSTS</code>. No loop, no comparison, no allocation.</p>
<h2>Database schema for bitmask permissions</h2>
<p>The standard approach uses a junction table (one row per user per permission). The row count multiplies:</p>
<pre><code class="language-sql">-- Junction table: 10M users × 5 permissions = 50M rows
CREATE TABLE user_permissions (
  user_id    INT,
  permission VARCHAR(50)
);
</code></pre>
<p>With bitmask encoding, you replace the entire table with a single integer column on the users table:</p>
<pre><code class="language-sql">-- Single column: 10M users = 10M rows regardless of permission count
ALTER TABLE users ADD COLUMN permissions INT NOT NULL DEFAULT 0;
</code></pre>
<p>Creating a user with an initial permission set combines the relevant constants with OR:</p>
<pre><code class="language-typescript">import { PERMISSIONS } from &quot;./permissions&quot;;

const userPerms = PERMISSIONS.CREATE_POSTS | PERMISSIONS.EDIT_OWN_POSTS;

await db.query(
  &quot;INSERT INTO users (email, password_hash, permissions) VALUES (?, ?, ?)&quot;,
  [email, passwordHash, userPerms]
);
</code></pre>
<p>Promoting a user works the same way: OR in the new permissions, then write back the result:</p>
<pre><code class="language-typescript">const newPerms = user.permissions | PERMISSIONS.DELETE_ALL_POSTS | PERMISSIONS.EDIT_ALL_POSTS;
await db.query(&quot;UPDATE users SET permissions = ? WHERE id = ?&quot;, [newPerms, userId]);
</code></pre>
<h2>Permission check middleware</h2>
<p>Three authorization shapes cover most route protection requirements. Each reads the permissions integer from the decoded token and runs a bitwise check with no database involved:</p>
<pre><code class="language-typescript">import { hasPermission, type Permission } from &quot;../lib/permissions&quot;;
import type { Request, Response, NextFunction } from &quot;express&quot;;

interface AuthRequest extends Request {
  user?: { id: string; permissions: number };
}

export function requirePermission(required: Permission) {
  return (req: AuthRequest, res: Response, next: NextFunction): void =&gt; {
    if (!req.user) {
      res.status(401).json({ error: &quot;Unauthorized&quot; });
      return;
    }
    if (!hasPermission(req.user.permissions, required)) {
      res.status(403).json({ error: &quot;Forbidden&quot; });
      return;
    }
    next();
  };
}

export function requireAllPermissions(...required: Permission[]) {
  return (req: AuthRequest, res: Response, next: NextFunction): void =&gt; {
    if (!req.user) {
      res.status(401).json({ error: &quot;Unauthorized&quot; });
      return;
    }
    const combined = required.reduce((acc, perm) =&gt; acc | perm, 0);
    if ((req.user.permissions &amp; combined) !== combined) {
      res.status(403).json({ error: &quot;Forbidden&quot; });
      return;
    }
    next();
  };
}

export function requireAnyPermission(...required: Permission[]) {
  return (req: AuthRequest, res: Response, next: NextFunction): void =&gt; {
    if (!req.user) {
      res.status(401).json({ error: &quot;Unauthorized&quot; });
      return;
    }
    if (!required.some((perm) =&gt; hasPermission(req.user!.permissions, perm))) {
      res.status(403).json({ error: &quot;Forbidden&quot; });
      return;
    }
    next();
  };
}
</code></pre>
<p><img src="https://reclear.io/blog/bitmask-permissions-role-based-access-control/diagram/auth-middleware-flow.webp" alt="Permission check flow: bitmask approach: verify JWT, extract integer, bitwise AND; traditional approach: verify JWT, query database, check row exists"/></p>
<p>Applying these to routes looks the same as any other Express middleware:</p>
<pre><code class="language-typescript">import { PERMISSIONS } from &quot;../lib/permissions&quot;;
import { requirePermission, requireAllPermissions } from &quot;../middleware/authorize&quot;;

app.delete(&quot;/posts/:id&quot;,
  requirePermission(PERMISSIONS.DELETE_ALL_POSTS),
  deletePostHandler
);

app.post(&quot;/analytics/export&quot;,
  requireAllPermissions(PERMISSIONS.VIEW_ANALYTICS, PERMISSIONS.EXPORT_DATA),
  exportHandler
);
</code></pre>
<h2>JWT integration and token size</h2>
<p>The bitmask encodes cleanly into any JWT payload. Compare the payload size of a string array against an integer for the same four permissions:</p>
<pre><code class="language-typescript">// String array in JWT payload: ~180 bytes
{ &quot;userId&quot;: 123, &quot;permissions&quot;: [&quot;create_posts&quot;, &quot;edit_own_posts&quot;, &quot;edit_all_posts&quot;, &quot;delete_own_posts&quot;] }

// Bitmask in JWT payload: ~50 bytes, 72% smaller
{ &quot;userId&quot;: 123, &quot;permissions&quot;: 15 }
</code></pre>
<p><img src="https://reclear.io/blog/bitmask-permissions-role-based-access-control/diagram/token-size-comparison.webp" alt="Token size comparison: bitmask payload is ~50 bytes with permissions as a single integer; string array payload is ~180 bytes, 72% larger"/></p>
<p>Token size matters because every request carries it. Smaller tokens reduce bandwidth and speed up sign and verify operations. HTTP headers have an 8 KB limit; a string array grows with each permission added.</p>
<p>The <a href="https://reclear.io/blog/session-tokens-vs-jwts-the-false-dichotomy">hybrid JWT/session approach</a> fits this pattern well. The bitmask goes into the short-lived access token (15 minutes); the refresh token is a database record. On login, sign both:</p>
<pre><code class="language-typescript">import jwt from &quot;jsonwebtoken&quot;;
import crypto from &quot;crypto&quot;;
import type { Response } from &quot;express&quot;;

interface TokenPayload {
  userId: string;
  permissions: number;
}

export async function createSession(
  userId: string,
  permissions: number,
  res: Response
): Promise&lt;void&gt; {
  const refreshToken = crypto.randomBytes(32).toString(&quot;hex&quot;);

  await db.session.create({
    data: {
      userId,
      refreshToken,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    },
  });

  const accessToken = jwt.sign(
    { userId, permissions } satisfies TokenPayload,
    process.env.JWT_SECRET!,
    { expiresIn: &quot;15m&quot; }
  );

  res.cookie(&quot;accessToken&quot;, accessToken, {
    httpOnly: true, secure: true, sameSite: &quot;lax&quot;, maxAge: 15 * 60 * 1000,
  });
  res.cookie(&quot;refreshToken&quot;, refreshToken, {
    httpOnly: true, secure: true, sameSite: &quot;lax&quot;, maxAge: 30 * 24 * 60 * 60 * 1000,
  });
}
</code></pre>
<p>The protect middleware verifies the access token on the fast path. When it expires, the refresh path re-signs a new one with current data from the database, so any permission changes take effect within one token lifetime:</p>
<pre><code class="language-typescript">import jwt from &quot;jsonwebtoken&quot;;
import type { Request, Response, NextFunction } from &quot;express&quot;;

interface TokenPayload { userId: string; permissions: number; }
interface AuthRequest extends Request { user?: TokenPayload; }

export async function protect(
  req: AuthRequest,
  res: Response,
  next: NextFunction
): Promise&lt;void&gt; {
  const { accessToken, refreshToken } = req.cookies as {
    accessToken?: string; refreshToken?: string;
  };

  try {
    req.user = jwt.verify(accessToken!, process.env.JWT_SECRET!) as TokenPayload;
    next();
    return;
  } catch {
    if (!refreshToken) {
      res.status(401).json({ error: &quot;Not authenticated&quot; });
      return;
    }

    const session = await db.session.findUnique({
      where: { refreshToken },
      include: { user: true },
    });

    if (!session || session.expiresAt &lt; new Date()) {
      res.status(401).json({ error: &quot;Session expired&quot; });
      return;
    }

    const newAccessToken = jwt.sign(
      { userId: session.userId, permissions: session.user.permissions } satisfies TokenPayload,
      process.env.JWT_SECRET!,
      { expiresIn: &quot;15m&quot; }
    );

    res.cookie(&quot;accessToken&quot;, newAccessToken, {
      httpOnly: true, secure: true, sameSite: &quot;lax&quot;, maxAge: 15 * 60 * 1000,
    });
    req.user = { userId: session.userId, permissions: session.user.permissions };
    next();
  }
}
</code></pre>
<p>When a permission is revoked, update the integer and invalidate the refresh token. The user&#x27;s current access token stays valid for up to 15 minutes, then the refresh check fails and they re-authenticate with the updated permissions:</p>
<pre><code class="language-typescript">export async function revokePermission(userId: string, perm: Permission): Promise&lt;void&gt; {
  const user = await db.user.findUnique({ where: { id: userId } });
  const newPerms = removePermission(user!.permissions, perm);

  await db.$transaction([
    db.user.update({ where: { id: userId }, data: { permissions: newPerms } }),
    db.session.deleteMany({ where: { userId } }),
  ]);
}
</code></pre>
<h2>Composing roles from permission flags</h2>
<p>Rather than assigning individual permissions to every user, define named roles as fixed combinations and assign those. Each role is the OR of its constituent permissions:</p>
<pre><code class="language-typescript">import { PERMISSIONS } from &quot;./permissions&quot;;

export const ROLES = {
  VIEWER: PERMISSIONS.CREATE_POSTS
    | PERMISSIONS.EDIT_OWN_POSTS,

  EDITOR: PERMISSIONS.CREATE_POSTS
    | PERMISSIONS.EDIT_OWN_POSTS
    | PERMISSIONS.EDIT_ALL_POSTS,

  MODERATOR: PERMISSIONS.CREATE_POSTS
    | PERMISSIONS.EDIT_OWN_POSTS
    | PERMISSIONS.EDIT_ALL_POSTS
    | PERMISSIONS.DELETE_OWN_POSTS
    | PERMISSIONS.DELETE_ALL_POSTS,

  ADMIN: PERMISSIONS.CREATE_POSTS
    | PERMISSIONS.EDIT_OWN_POSTS
    | PERMISSIONS.EDIT_ALL_POSTS
    | PERMISSIONS.DELETE_OWN_POSTS
    | PERMISSIONS.DELETE_ALL_POSTS
    | PERMISSIONS.MANAGE_USERS
    | PERMISSIONS.VIEW_ANALYTICS
    | PERMISSIONS.EXPORT_DATA,
} as const;
</code></pre>
<p><img src="https://reclear.io/blog/bitmask-permissions-role-based-access-control/diagram/role-hierarchy.webp" alt="Role hierarchy: individual permission bits compose into VIEWER (3), EDITOR (7), and MODERATOR (31) by OR-ing the relevant bit values"/></p>
<p>Assigning a role is a single database write. You can still layer individual permissions on top of a role at assignment time:</p>
<pre><code class="language-typescript">// Assign the moderator role
await db.query(&quot;UPDATE users SET permissions = ? WHERE id = ?&quot;, [ROLES.MODERATOR, userId]);

// Moderator with export access added on
const customPerms = ROLES.MODERATOR | PERMISSIONS.EXPORT_DATA;

// Admin without user management
const restrictedAdmin = ROLES.ADMIN &amp; ~PERMISSIONS.MANAGE_USERS;
</code></pre>
<h2>Security considerations and debugging bitmask permissions</h2>
<p><strong>Permission staleness.</strong> The bitmask in the access token reflects the permissions at the time it was signed. If you change permissions server-side, the token carries the old values until it expires. The mitigation is short-lived access tokens (5–15 minutes) combined with refresh token invalidation, as shown above.</p>
<p><strong>Bit position stability.</strong> A bit position assigned to one permission must never be reassigned to a different one. Old tokens and stored integers carry the old meaning. Treat the permission schema like a database migration: only ever add new positions at the end, never reuse existing ones.</p>
<p><strong>Principle of least privilege.</strong> Give roles only the permissions they need. An ADMIN role with every bit set makes it impossible to grant limited administrative access without granting everything.</p>
<p><strong>Audit logging.</strong> Log permission failures so you can detect privilege escalation attempts and misconfiguration:</p>
<pre><code class="language-typescript">if (!hasPermission(req.user.permissions, required)) {
  logger.warn(&quot;permission_denied&quot;, {
    userId: req.user.id,
    required: debugPermissions(required),
    actual: debugPermissions(req.user.permissions),
    path: req.path,
    method: req.method,
  });
  res.status(403).json({ error: &quot;Forbidden&quot; });
  return;
}
</code></pre>
<p>The <code>debugPermissions</code> helper maps a permissions integer back to readable names for log output:</p>
<pre><code class="language-typescript">export function debugPermissions(perms: number): string[] {
  return Object.entries(PERMISSIONS)
    .filter(([, value]) =&gt; (perms &amp; value) !== 0)
    .map(([name]) =&gt; name);
}

// debugPermissions(87) =&gt; [&quot;CREATE_POSTS&quot;, &quot;EDIT_OWN_POSTS&quot;, &quot;DELETE_OWN_POSTS&quot;, &quot;MANAGE_USERS&quot;]
</code></pre>
<h2>When this breaks down</h2>
<p><strong>32-permission ceiling.</strong> JavaScript&#x27;s bitwise operators coerce to signed 32-bit integers. You can extend to 53 using regular arithmetic, or use <code>BigInt</code> for more:</p>
<pre><code class="language-typescript">export const PERMISSIONS = {
  PERMISSION_1:  1n &lt;&lt; 0n,
  PERMISSION_65: 1n &lt;&lt; 64n,
} as const;

export const hasPermission = (perms: bigint, required: bigint): boolean =&gt;
  (perms &amp; required) !== 0n;
</code></pre>
<ul>
<li>
<p><strong>No dynamic permissions.</strong> Bit positions must be defined at deploy time. If your product lets customers define their own permission types, you need a junction table with a caching layer instead.</p>
</li>
<li>
<p><strong>Migration cost.</strong> Converting an existing string-based permission system requires mapping names to bit positions and backfilling the column. The migration is straightforward but needs a dual-read window during the transition.</p>
</li>
<li>
<p><strong>Highly dynamic permissions.</strong> If your system grants and revokes individual permissions multiple times per day per user, the 15-minute staleness window may be too large. In that case, Redis-backed sessions with a permission lookup on each refresh are a better fit. The lookup adds latency, but the revocation is immediate.</p>
</li>
<li>
<p><strong>Resource-level access control.</strong> This pattern is not suitable for controlling which specific documents a user can access, hierarchical permissions, or complex conditional logic. For those requirements: Casbin, Ory, or an ABAC policy engine.</p>
</li>
</ul>
<h2>Key takeaways</h2>
<p>Encoding permissions as a single integer in the JWT payload turns every permission check into one bitwise AND instruction with no database call, no Redis lookup, and no network round trip. A bitmask token is 72% smaller than one carrying a string array, and one INT column in the database replaces an entire junction table: at 10 million users with 5 permissions each, that is 10 million rows instead of 50 million. Bit positions are permanent once assigned: adding permissions is safe, but reusing a position corrupts every stored integer and every live token that carries the old encoding. The pattern is not a fit for resource-level access control, highly dynamic permission systems where revocation must be immediate, or permission sets larger than 31 entries unless you move to BigInt arithmetic.</p>]]></content:encoded>
      <pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Wale Bashir)</author>
      <category>Tutorials</category>
      <category>Authentication</category>
      <category>Security</category>
      <category>API</category>
      <enclosure url="https://reclear.io/blog/bitmask-permissions-role-based-access-control/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Field-Level Audit Logs: The Diff Primitive Every B2B API Needs</title>
      <link>https://reclear.io/blog/object-diff-change-tracking-audit-logs</link>
      <guid isPermaLink="true">https://reclear.io/blog/object-diff-change-tracking-audit-logs</guid>
      <description>Enterprise customers don&apos;t just want an audit log. They want to know which field changed, from what, to what, and by whom. Most implementations get this wrong. This is the diff function that gets it right.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/object-diff-change-tracking-audit-logs/cover/cover.webp" alt="Field-Level Audit Logs: The Diff Primitive Every B2B API Needs" /></p><aside><ul>
<li>&quot;User updated their profile&quot; is not an audit log. Enterprise customers want which field changed, from what value, to what value, and by whom. That requires a diff function, not a log statement.</li>
<li><code>===</code> compares object references, not values. Two separately created objects with identical content are never <code>===</code>. <code>JSON.stringify</code> comparison tells you something changed but not what, and breaks on key ordering, <code>undefined</code>, and <code>Date</code> objects.</li>
<li>A diff function returns <code>{ field: { oldValue, newValue } }</code> for every changed key. That record drives audit log entries, PATCH request bodies, and webhook change payloads from a single implementation.</li>
<li>Always read the <code>before</code> snapshot from the database immediately before the update, not from cache. A stale snapshot produces an audit entry describing a transition that never happened.</li>
</ul></aside>
<p>Every enterprise sales call eventually surfaces the same request: &quot;Can you show us who changed what and when?&quot; Most teams answer yes and then ship &quot;User X updated Resource Y&quot;: a timestamp and an actor ID, with no detail about what actually changed.</p>
<p>This article covers how to produce field-level audit records that name the specific fields, their before values, and their after values, and the diff function that makes this possible.</p>
<h2>What the output should look like</h2>
<p>Before writing any code, it helps to be clear about what a useful audit entry looks like. A basic log entry records that something happened:</p>
<pre><code class="language-json">{
  &quot;timestamp&quot;: &quot;2026-05-31T14:22:01.000Z&quot;,
  &quot;actorId&quot;: &quot;usr_admin_01&quot;,
  &quot;action&quot;: &quot;user.updated&quot;,
  &quot;resourceId&quot;: &quot;usr_alice_99&quot;
}
</code></pre>
<p>An enterprise-grade entry names exactly what changed:</p>
<pre><code class="language-json">{
  &quot;timestamp&quot;: &quot;2026-05-31T14:22:01.000Z&quot;,
  &quot;actorId&quot;: &quot;usr_admin_01&quot;,
  &quot;resourceType&quot;: &quot;user&quot;,
  &quot;resourceId&quot;: &quot;usr_alice_99&quot;,
  &quot;changes&quot;: {
    &quot;role&quot;: { &quot;oldValue&quot;: &quot;viewer&quot;, &quot;newValue&quot;: &quot;editor&quot; }
  }
}
</code></pre>
<p>The <code>changes</code> field is what separates a queryable audit trail from a timestamp list. With it, you can answer &quot;when did Alice&#x27;s role change?&quot; by filtering on <code>changes.role</code>. Without it, you can only answer &quot;when did something change on Alice&#x27;s record?&quot;</p>
<p>Producing the <code>changes</code> field means comparing the object before the update and after it, then returning only the keys that differ along with both values. That is exactly what a diff function does.</p>
<h2>Why naive comparisons fail</h2>
<p>The first instinct is to compare the before and after objects directly. In JavaScript, <code>===</code> performs reference comparison on objects, not value comparison. Two separately allocated objects with identical content are never <code>===</code>:</p>
<pre><code class="language-typescript">const a = { name: &quot;Alice&quot;, role: &quot;editor&quot; };
const b = { name: &quot;Alice&quot;, role: &quot;editor&quot; }; // same content, different object

console.log(a === b); // false
</code></pre>
<p>There is no combination of identical values that makes this return <code>true</code>. The operator is comparing memory addresses, not field values, so it cannot tell you what changed or even whether anything did.</p>
<p><code>JSON.stringify</code> gets past the reference problem by comparing serialized strings:</p>
<pre><code class="language-typescript">const changed = JSON.stringify(before) !== JSON.stringify(after);
</code></pre>
<p>This correctly tells you that something changed. It still gives you nothing about what changed, only a boolean. It also breaks in ways that matter at runtime: keys serialized in different orders produce different strings for identical objects, <code>undefined</code> values are silently dropped, <code>Date</code> objects lose their type, and circular references throw. None of these is theoretical; each appears in real domain objects.</p>
<p>Neither approach can produce <code>{ role: { oldValue: &quot;viewer&quot;, newValue: &quot;editor&quot; } }</code>. You need something that iterates the fields.</p>
<h2>Writing a field-level diff function</h2>
<p>A diff function iterates all keys across both objects and returns only the ones where the values differ. Each entry in the result holds the old and new value, which is exactly the shape the audit entry needs:</p>
<pre><code class="language-typescript">export interface FieldChange&lt;T = unknown&gt; {
  oldValue: T;
  newValue: T;
}

export type DiffResult&lt;T extends object&gt; = Partial&lt;Record&lt;keyof T, FieldChange&gt;&gt;;

export function diff&lt;T extends object&gt;(before: T, after: T): DiffResult&lt;T&gt; {
  const changes: DiffResult&lt;T&gt; = {};

  const allKeys = new Set([
    ...Object.keys(before),
    ...Object.keys(after),
  ]) as Set&lt;keyof T&gt;;

  for (const key of allKeys) {
    if (!isEqual(before[key], after[key])) {
      changes[key] = { oldValue: before[key], newValue: after[key] } as FieldChange;
    }
  }

  return changes;
}

function isEqual(a: unknown, b: unknown): boolean {
  if (a === b) return true;
  if (a === null || b === null) return false;
  if (typeof a !== typeof b) return false;
  if (typeof a !== &quot;object&quot;) return false;

  if (a instanceof Date &amp;&amp; b instanceof Date) {
    return a.getTime() === b.getTime();
  }

  if (Array.isArray(a) &amp;&amp; Array.isArray(b)) {
    if (a.length !== b.length) return false;
    return a.every((item, i) =&gt; isEqual(item, (b as unknown[])[i]));
  }

  if (Array.isArray(a) !== Array.isArray(b)) return false;

  const aKeys = Object.keys(a as object);
  const bKeys = Object.keys(b as object);
  if (aKeys.length !== bKeys.length) return false;

  return aKeys.every((k) =&gt;
    isEqual(
      (a as Record&lt;string, unknown&gt;)[k],
      (b as Record&lt;string, unknown&gt;)[k]
    )
  );
}
</code></pre>
<p><code>isEqual</code> handles the cases <code>JSON.stringify</code> misses: <code>Date</code> comparison by value, type mismatches, <code>undefined</code> vs absent keys, and nested objects.</p>
<p>Running it against the role change from the audit entry above:</p>
<pre><code class="language-typescript">const before = { name: &quot;Alice&quot;, role: &quot;viewer&quot;, active: true };
const after  = { name: &quot;Alice&quot;, role: &quot;editor&quot;, active: true };

const changes = diff(before, after);
// { role: { oldValue: &quot;viewer&quot;, newValue: &quot;editor&quot; } }
</code></pre>
<p>Only <code>role</code> appears. <code>name</code> and <code>active</code> are identical and excluded. The output is exactly the <code>changes</code> field the audit entry needs.</p>
<h2>Writing structured audit log entries</h2>
<p>With <code>diff</code> in place, the audit handler takes a before and after snapshot, computes the diff, and writes a structured entry only when something actually changed:</p>
<pre><code class="language-typescript">import { diff, type DiffResult } from &quot;./diff&quot;;

interface AuditEntry {
  timestamp: string;
  actorId: string;
  resourceType: string;
  resourceId: string;
  changes: DiffResult&lt;object&gt;;
}

export async function logChange&lt;T extends object&gt;(
  actorId: string,
  resourceType: string,
  resourceId: string,
  before: T,
  after: T
): Promise&lt;void&gt; {
  const changes = diff(before, after);

  if (Object.keys(changes).length === 0) return;

  await db.auditLog.create({
    data: {
      timestamp: new Date().toISOString(),
      actorId,
      resourceType,
      resourceId,
      changes,
    } satisfies AuditEntry,
  });
}
</code></pre>
<p>Wire it into a PATCH route by reading <code>before</code> from the database, applying the update, then passing both snapshots to <code>logChange</code>:</p>
<pre><code class="language-typescript">app.patch(&quot;/users/:id&quot;, protect, async (req, res) =&gt; {
  const before = await db.user.findUnique({ where: { id: req.params.id } });

  if (!before) {
    return res.status(404).json({ error: &quot;Not found&quot; });
  }

  const after = await db.user.update({
    where: { id: req.params.id },
    data: req.body,
  });

  await logChange(req.user!.id, &quot;user&quot;, req.params.id, before, after);

  res.json(after);
});
</code></pre>
<p>The resulting entry for the role change:</p>
<pre><code class="language-json">{
  &quot;timestamp&quot;: &quot;2026-05-31T14:22:01.000Z&quot;,
  &quot;actorId&quot;: &quot;usr_admin_01&quot;,
  &quot;resourceType&quot;: &quot;user&quot;,
  &quot;resourceId&quot;: &quot;usr_alice_99&quot;,
  &quot;changes&quot;: {
    &quot;role&quot;: { &quot;oldValue&quot;: &quot;viewer&quot;, &quot;newValue&quot;: &quot;editor&quot; }
  }
}
</code></pre>
<p>A user object with 20 fields where one changed produces a one-key <code>changes</code> object. That entry is queryable: <code>WHERE changes-&gt;&gt;&#x27;role&#x27; IS NOT NULL</code> returns every role change across all users and all time.</p>
<h2>Why stale before-snapshots corrupt your diff</h2>
<p>The diff is only as accurate as the <code>before</code> snapshot. The most common mistake is reading it from a cache:</p>
<pre><code class="language-typescript">// Wrong: may reflect state from minutes ago
const before = await cache.get(`user:${userId}`);

// Right: fresh, immediately before the write
const before = await db.user.findUnique({ where: { id: userId } });
</code></pre>
<p>Consider a cached snapshot showing <code>role: &quot;viewer&quot;</code> while the database already holds <code>role: &quot;moderator&quot;</code>, updated by a concurrent request. The diff produces <code>viewer -&gt; editor</code>. The actual transition was <code>moderator -&gt; editor</code>. The audit log is wrong and cannot be corrected after the fact.</p>
<p>Always read <code>before</code> from the database immediately before the update you intend to audit. The extra read is the cost of accurate records.</p>
<aside><p>When updating multiple fields in one request, wrap the read and write in a transaction so no other update can change the record between your <code>before</code> read and your <code>after</code> write.</p></aside>
<h2>PATCH bodies and webhook payloads</h2>
<p>The <code>DiffResult</code> that feeds <code>logChange</code> also covers two other patterns without additional work.</p>
<p><strong>PATCH bodies.</strong> Extract the new values from the diff to send only what changed. A form with 20 fields where the user changed one sends a one-key request body, and concurrent edits to different fields no longer overwrite each other:</p>
<pre><code class="language-typescript">const changes = diff(localBefore, localAfter);

const patch = Object.fromEntries(
  Object.entries(changes).map(([key, change]) =&gt; [key, change!.newValue])
);

await fetch(`/api/users/${userId}`, {
  method: &quot;PATCH&quot;,
  headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
  body: JSON.stringify(patch),
});
</code></pre>
<p><strong>Webhook payloads.</strong> Include the diff alongside the full updated resource so subscribers can react to specific field changes without maintaining their own copy of the previous state:</p>
<pre><code class="language-typescript">await sendWebhook({
  event: &quot;user.updated&quot;,
  timestamp: new Date().toISOString(),
  data: after,
  changes: diff(before, after),
});
</code></pre>
<p>A subscriber checks <code>event.changes.role</code> to detect a role change without storing and comparing the full user record on every event.</p>
<h2>When this breaks down</h2>
<p><strong>Array ordering.</strong> <code>isEqual</code> treats arrays as ordered sequences. <code>[1, 2, 3]</code> and <code>[3, 2, 1]</code> are different. For unordered sets like tag lists or member IDs, sort both arrays before diffing or compare as sets explicitly.</p>
<p><strong>Type coercion.</strong> <code>&quot;30&quot;</code> and <code>30</code> are not equal and will appear as a change. This is intentional: a type change is a real change. If your input layer returns strings for numeric fields, normalize before diffing.</p>
<p><strong>Deeply nested objects.</strong> When a nested field changes, <code>diff</code> records the entire parent key as changed. For audit entries that need to name the exact nested path, a path-based diff is more appropriate:</p>
<pre><code class="language-typescript">export interface PathChange {
  path: string;
  oldValue: unknown;
  newValue: unknown;
}

export function deepDiff(before: unknown, after: unknown, path = &quot;&quot;): PathChange[] {
  if (before === after) return [];

  if (typeof before !== &quot;object&quot; || before === null ||
      typeof after  !== &quot;object&quot; || after  === null) {
    return [{ path: path || &quot;.&quot;, oldValue: before, newValue: after }];
  }

  const allKeys = new Set([
    ...Object.keys(before as object),
    ...Object.keys(after  as object),
  ]);

  return [...allKeys].flatMap((key) =&gt; {
    const childPath = path ? `${path}.${key}` : key;
    return deepDiff(
      (before as Record&lt;string, unknown&gt;)[key],
      (after  as Record&lt;string, unknown&gt;)[key],
      childPath
    );
  });
}
</code></pre>
<p><code>deepDiff</code> produces entries like <code>{ path: &quot;address.city&quot;, oldValue: &quot;Lagos&quot;, newValue: &quot;Abuja&quot; }</code> instead of recording the entire <code>address</code> object as changed. Use it when the audit entry needs to name the exact field rather than the parent object.</p>]]></content:encoded>
      <pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Wale Bashir)</author>
      <category>Tutorials</category>
      <category>API</category>
      <category>Backend</category>
      <category>Nodejs</category>
      <enclosure url="https://reclear.io/blog/object-diff-change-tracking-audit-logs/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Session Tokens vs JWTs: The False Dichotomy</title>
      <link>https://reclear.io/blog/session-tokens-vs-jwts-the-false-dichotomy</link>
      <guid isPermaLink="true">https://reclear.io/blog/session-tokens-vs-jwts-the-false-dichotomy</guid>
      <description>Most auth tutorials force you to pick JWTs or sessions. Here is why that framing is wrong, and how the hybrid approach gives you the performance of JWTs with the revocation control of sessions.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/session-tokens-vs-jwts-the-false-dichotomy/cover/cover.webp" alt="Session Tokens vs JWTs: The False Dichotomy" /></p><aside><ul>
<li>JWT-only auth benchmarks at 5,527 req/s with zero database calls, but tokens cannot be revoked mid-lifetime. A deleted account retains full access until the JWT expires, up to 7 days by default.</li>
<li>Session-only auth gives instant revocation but queries the database on every request. At 4,561 req/s that is 4,561 auth queries per second, making auth the bottleneck for every endpoint.</li>
<li>The hybrid uses a 15-minute access JWT for the fast path and a long-lived refresh token in the database. 99% of requests skip the database entirely; revocation takes effect within one 15-minute window.</li>
<li>Benchmarked at 5,494 req/s and 0.51ms average, the hybrid outperforms JWT-only because the fast path skips connection pool overhead entirely.</li>
</ul></aside>
<p>A user gets fired. The admin deletes their account from the database. Their JWT is still valid for the next seven days, because the token does not know the database changed. They can still authenticate, read data, and act on behalf of a now-deleted account.</p>
<p>This article covers both auth approaches, why each fails under realistic conditions, and the hybrid pattern that gives you the performance of JWTs with the revocation control of sessions, backed by benchmarks on real PostgreSQL workloads.</p>
<h2>The JWT-only approach</h2>
<p>JWT-only auth is stateless: the server signs a token on login, and every subsequent request is verified cryptographically without consulting any external store. A typical implementation looks like this:</p>
<pre><code class="language-typescript">import jwt from &quot;jsonwebtoken&quot;;
import type { Response } from &quot;express&quot;;

// On login
const token = jwt.sign(
  { userId: user.id, role: user.role },
  process.env.JWT_SECRET!,
  { expiresIn: &quot;7d&quot; }
);
res.cookie(&quot;token&quot;, token, { httpOnly: true });

// On every request
const payload = jwt.verify(req.cookies.token, process.env.JWT_SECRET!);
</code></pre>
<p>No database queries on the hot path. Any server with the secret can verify the token. The check completes in under one millisecond.</p>
<p>The problems appear when you need to revoke access. Because the token is self-contained, the server has no mechanism to reject a valid, unexpired token without consulting external state:</p>
<p><strong>Fired employee.</strong> The JWT carries a valid expiry signed by the server secret. Deleting the user&#x27;s database record does not invalidate it. The user retains full access for up to seven days.</p>
<p><strong>Role change.</strong> The token carries <code>role: &quot;admin&quot;</code>. Downgrading the user in the database does not change what the token says until it expires and they re-authenticate.</p>
<p><strong>Device revocation.</strong> A user&#x27;s device is stolen. There is no mechanism to invalidate its token before expiry.</p>
<p><strong>Benchmarked with PostgreSQL, 100 concurrent connections:</strong></p>
<table><thead><tr><th style="text-align:left">Metric</th><th style="text-align:left">JWT-only</th></tr></thead><tbody><tr><td style="text-align:left">Average response time</td><td style="text-align:left">0.97ms</td></tr><tr><td style="text-align:left">Throughput</td><td style="text-align:left">5,527 req/sec</td></tr><tr><td style="text-align:left">Database queries per second</td><td style="text-align:left">0</td></tr></tbody></table>
<p>The performance is real. So is the revocation problem.</p>
<p>The usual defence is that microservices require stateless auth because services cannot share state. In practice, even Netflix-scale architectures share infrastructure: caches, message queues, logging pipelines. The session store concern is real at genuine distributed systems scale; most teams are nowhere near it.</p>
<h2>The session-only approach</h2>
<p>Sessions solve revocation completely by keeping authority in the database. On every request, the server fetches the session record and checks it is still valid:</p>
<pre><code class="language-typescript">import crypto from &quot;crypto&quot;;
import type { Response } from &quot;express&quot;;

// On login
const sessionId = crypto.randomBytes(32).toString(&quot;hex&quot;);
await db.session.create({
  data: {
    id: sessionId,
    userId: user.id,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
  },
});
res.cookie(&quot;sessionId&quot;, sessionId, { httpOnly: true });

// On every request
const session = await db.session.findUnique({
  where: { id: req.cookies.sessionId },
  include: { user: true },
});

if (!session || session.expiresAt &lt; new Date()) {
  return res.status(401).json({ error: &quot;Invalid session&quot; });
}
</code></pre>
<p>Delete the session row and the user is locked out immediately. Every revocation scenario from the JWT section is handled, because the database is always the source of truth.</p>
<p>The cost is a database query on every request, without exception. A dashboard that makes 50 API calls on load runs 50 auth queries before any business logic executes:</p>
<p><strong>Benchmarked with PostgreSQL, 100 concurrent connections:</strong></p>
<table><thead><tr><th style="text-align:left">Metric</th><th style="text-align:left">Session-only</th></tr></thead><tbody><tr><td style="text-align:left">Average response time</td><td style="text-align:left">1.52ms</td></tr><tr><td style="text-align:left">Throughput</td><td style="text-align:left">4,561 req/sec</td></tr><tr><td style="text-align:left">Database queries per second</td><td style="text-align:left">4,561</td></tr></tbody></table>
<p>At 4,561 requests per second, the database absorbs 4,561 auth queries per second. As traffic grows, this bottlenecks every endpoint regardless of how well the business logic is optimized.</p>
<p><img src="https://reclear.io/blog/session-tokens-vs-jwts-the-false-dichotomy/diagram/flow-comparison.webp" alt="Flow comparison: JWT-only skips the database on every request; session-only hits it on every request; the hybrid hits it only on token refresh"/></p>
<h2>Implementing the hybrid auth pattern</h2>
<p>The resolution is to use both mechanisms for what each is designed for. A short-lived JWT handles the fast path; a database-backed refresh token provides revocation control.</p>
<p>An <strong>access token</strong> is a 15-minute JWT. It carries enough data to authenticate requests without a database call on the common path.</p>
<p>A <strong>refresh token</strong> is a random string stored in the database with a 30-day lifetime. It is only used when the access token expires: the middleware validates the session, fetches fresh user data, and issues a new access token.</p>
<p><code>createSession</code> writes the refresh token to the database and sends both tokens as cookies:</p>
<pre><code class="language-typescript">import jwt from &quot;jsonwebtoken&quot;;
import crypto from &quot;crypto&quot;;
import type { Response } from &quot;express&quot;;

interface TokenPayload {
  userId: string;
  role: string;
}

export async function createSession(
  userId: string,
  role: string,
  res: Response
): Promise&lt;void&gt; {
  const refreshToken = crypto.randomBytes(32).toString(&quot;hex&quot;);

  await db.session.create({
    data: {
      userId,
      refreshToken,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    },
  });

  const accessToken = jwt.sign(
    { userId, role } as TokenPayload,
    process.env.JWT_SECRET!,
    { expiresIn: &quot;15m&quot; }
  );

  res.cookie(&quot;accessToken&quot;, accessToken, {
    httpOnly: true,
    secure: true,
    sameSite: &quot;lax&quot;,
    maxAge: 15 * 60 * 1000,
  });
  res.cookie(&quot;refreshToken&quot;, refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: &quot;lax&quot;,
    maxAge: 30 * 24 * 60 * 60 * 1000,
  });
}
</code></pre>
<p>The <code>protect</code> middleware tries the fast path first. When the access token is valid, it passes through in under a millisecond. When it is expired or absent, the middleware falls back to the refresh token, queries the database once, and issues a new access token:</p>
<pre><code class="language-typescript">import jwt from &quot;jsonwebtoken&quot;;
import type { Request, Response, NextFunction } from &quot;express&quot;;

interface TokenPayload {
  userId: string;
  role: string;
}

interface AuthRequest extends Request {
  userId?: string;
}

export async function protect(
  req: AuthRequest,
  res: Response,
  next: NextFunction
): Promise&lt;void&gt; {
  const { accessToken, refreshToken } = req.cookies as {
    accessToken?: string;
    refreshToken?: string;
  };

  try {
    const payload = jwt.verify(
      accessToken!,
      process.env.JWT_SECRET!
    ) as TokenPayload;

    req.userId = payload.userId;
    next();
    return;
  } catch {
    // Access token expired or absent; attempt refresh
    if (!refreshToken) {
      res.status(401).json({ error: &quot;Not authenticated&quot; });
      return;
    }

    const session = await db.session.findUnique({
      where: { refreshToken },
      include: { user: true },
    });

    if (!session || session.expiresAt &lt; new Date()) {
      res.status(401).json({ error: &quot;Session expired&quot; });
      return;
    }

    const newAccessToken = jwt.sign(
      { userId: session.userId, role: session.user.role } as TokenPayload,
      process.env.JWT_SECRET!,
      { expiresIn: &quot;15m&quot; }
    );

    res.cookie(&quot;accessToken&quot;, newAccessToken, {
      httpOnly: true,
      secure: true,
      sameSite: &quot;lax&quot;,
      maxAge: 15 * 60 * 1000,
    });
    req.userId = session.userId;
    next();
  }
}
</code></pre>
<p>99% of requests hit the fast path. The other 1% run one database query, fetch fresh user data, and issue a new access token.</p>
<p><img src="https://reclear.io/blog/session-tokens-vs-jwts-the-false-dichotomy/diagram/token-lifecycle.webp" alt="Token lifecycle: fast path skips the database; token refresh triggers one database query and issues a fresh access token"/></p>
<p>On the revocation scenarios from earlier: deleting the session row locks the user out within one 15-minute token lifetime. A role change takes effect the next time the access token refreshes. Deleting all session rows for a user logs out every device simultaneously.</p>
<p><strong>Benchmarked with PostgreSQL, 100 concurrent connections:</strong></p>
<table><thead><tr><th style="text-align:left">Metric</th><th style="text-align:left">Hybrid</th></tr></thead><tbody><tr><td style="text-align:left">Average response time</td><td style="text-align:left">0.51ms</td></tr><tr><td style="text-align:left">Throughput</td><td style="text-align:left">5,494 req/sec</td></tr><tr><td style="text-align:left">Database queries per second</td><td style="text-align:left">~0 (only on token refresh, ~1% of requests)</td></tr></tbody></table>
<p>The hybrid outperforms JWT-only because access token verification has no database connection overhead, not even a pool checkout. Full benchmark methodology: <a href="https://github.com/digitaldrreamer/stat-tests/blob/main/test-three-auth-strategies/RESULTS.md">stat-tests/RESULTS.md</a>.</p>
<p><img src="https://reclear.io/blog/session-tokens-vs-jwts-the-false-dichotomy/diagram/database-load-comparison.webp" alt="Database load comparison: JWT-only and hybrid are near-zero; session-only scales linearly with request volume"/></p>
<h2>Tightening the hybrid for high-security requirements</h2>
<p>For systems where a 15-minute revocation window is not acceptable, two options tighten it further.</p>
<p><strong>Shorter access token lifetime.</strong> Reducing expiry to 5 minutes or 1 minute keeps the same 99%/1% fast-path ratio but forces more frequent refreshes. The tradeoff is higher per-user database load at scale.</p>
<p><strong>Redis token blacklist.</strong> Maintain a set of revoked token IDs in Redis and check it on every request. This middleware adds the blacklist check before passing through:</p>
<pre><code class="language-typescript">import jwt from &quot;jsonwebtoken&quot;;
import { redis } from &quot;../lib/redis&quot;;
import type { Request, Response, NextFunction } from &quot;express&quot;;

interface TokenPayload {
  userId: string;
  role: string;
  jti: string; // JWT ID claim, required for blacklisting
}

interface AuthRequest extends Request {
  userId?: string;
}

export async function protect(
  req: AuthRequest,
  res: Response,
  next: NextFunction
): Promise&lt;void&gt; {
  try {
    const payload = jwt.verify(
      req.cookies.accessToken,
      process.env.JWT_SECRET!
    ) as TokenPayload;

    const isRevoked = await redis.get(`blacklist:${payload.jti}`);
    if (isRevoked) {
      res.status(401).json({ error: &quot;Token revoked&quot; });
      return;
    }

    req.userId = payload.userId;
    next();
  } catch {
    // Refresh token flow (same as above)...
  }
}
</code></pre>
<aside><p>Set each blacklist entry&#x27;s TTL to match the remaining lifetime of the revoked token. This prevents unbounded growth while still covering the revocation window. Include a <code>jti</code> claim when signing access tokens so you have a stable key to write to Redis.</p></aside>
<p>The Redis check adds approximately 1 millisecond per request but provides immediate revocation without waiting for token expiry.</p>
<h2>When this breaks down</h2>
<p>The hybrid approach is the right default for most applications, but three situations call for a different choice.</p>
<p><strong>Very short TTLs already.</strong> If your access tokens already expire in under 5 minutes across the board, JWT-only is acceptable. The revocation window is small enough that the operational risk is low, and the implementation is simpler.</p>
<p><strong>Under 10 requests/sec with no scaling plans.</strong> At very low traffic the database load from session-only auth is negligible. The simplicity of a single lookup per request may be worth more than the performance headroom.</p>
<p><strong>Permissions that change multiple times per day.</strong> The 15-minute staleness window means a permission change takes effect at the next token refresh, not immediately. For systems where instant permission propagation is a hard requirement (financial platforms, healthcare, admin tooling), use 5-minute tokens or add a Redis blacklist keyed by <code>jti</code>.</p>
<p>The tradeoff between JWTs and sessions is real. The choice between them is not.</p>]]></content:encoded>
      <pubDate>Sun, 31 May 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Wale Bashir)</author>
      <category>Tutorials</category>
      <category>Authentication</category>
      <category>Security</category>
      <category>API</category>
      <enclosure url="https://reclear.io/blog/session-tokens-vs-jwts-the-false-dichotomy/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Docker EXPOSE vs Ports: What is the Difference?</title>
      <link>https://reclear.io/blog/docker-ports-vs-expose</link>
      <guid isPermaLink="true">https://reclear.io/blog/docker-ports-vs-expose</guid>
      <description>Learn the exact difference between Docker ports and expose directives, how they manage network boundaries, and when to use each configuration.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/docker-ports-vs-expose/cover/cover.webp" alt="Docker EXPOSE vs Ports: What is the Difference?" /></p><aside><p>The <code>ports</code> directive maps a host port to a container port so you can reach a service from your machine or the public internet (for example, <code>localhost:8080</code> to Nginx on port 80 inside the container). The <code>expose</code> directive does not open anything on the host; it documents which ports a container listens on and keeps them available only to other containers on the same Docker network. Use <code>ports</code> for frontends, APIs, and local dev entrypoints. Use <code>expose</code> for databases, caches, and internal microservices you want off the host network.</p></aside>
<p>In Docker, mixing up the <code>ports</code> and <code>expose</code> directives in your configuration files can quickly lead to broken container communication or unintended security risks. While both options define how network ports are handled, they serve two completely different purposes in a Docker network.</p>
<p>In this article, we will compare <code>ports</code> and <code>expose</code> side-by-side. We will look at exactly how they work, break down their real-world use cases, and provide practical configuration examples so you know precisely when to use each one.</p>
<h2>Understanding Docker Networking Baselines</h2>
<p>To understand the difference between <code>ports</code> and <code>expose</code>, you must first understand how Docker manages network boundaries.</p>
<p>By default, when you spin up a container or a group of services using a <code>docker-compose.yml</code> file, Docker isolates them entirely from your host machine (your laptop or server) and the public internet. They are placed inside an isolated virtual network specific to that project. The infographic below visualizes this core concept.</p>
<p><img src="https://reclear.io/blog/docker-ports-vs-expose/diagram/docker-networking-baselines.webp" alt="Understanding Docker Networking Baselines"/></p>
<p>Referencing the visualization above, we can break Docker’s network baseline into three distinct operating phases:</p>
<ol>
<li>
<p><strong>Isolation Baseline</strong>: As the first panel illustrates, Docker defaults to security. A robust boundary (a &#x27;Private Network&#x27;) surrounds the containers, blocking all public or local host traffic trying to enter. The containers share an internal network that is invisible to the host by default.</p>
</li>
<li>
<p><strong>Internal East-West Communication</strong>: The second panel shows the magic that happens inside that bubble. Containers on the same network can communicate freely and seamlessly on all internal ports. For example, a web application container can connect to a database container on its standard port (like 5432 for PostgreSQL) using the container name. No ports need to be &#x27;opened&#x27; to the host machine for this to work.</p>
</li>
<li>
<p><strong>Boundary Decisions</strong>: The final panel represents the decision point. The default state is secure. When you decide to move away from this secure baseline, you use configuration switches (<code>ports</code> or <code>expose</code>) to explicitly define boundary accessibility either to allow public traffic or to refine internal access.</p>
</li>
</ol>
<p>The <code>ports</code> and <code>expose</code> directives are the tools you use to manipulate this secure boundary, but they open those network doors for completely different audiences.</p>
<h2>The Ports Directive (External Access) Explained</h2>
<p>The <code>ports</code> directive is the configuration switch you turn on when you want to open your containers to the outside world. If you need to access an application container from your local host machine’s web browser (via <code>localhost</code>) or expose a service to public internet traffic, you must use ports.</p>
<p>When you specify a port under this directive, Docker performs port mapping (also known as port forwarding). It intercepts traffic hitting a specific port on your physical host machine and routes it directly into a specific port inside your isolated Docker container.</p>
<p>The syntax for mapping ports in a <code>docker-compose.yml</code> file follows a strict pattern: <code>HOST_PORT:CONTAINER_PORT</code>.</p>
<ul>
<li>
<p><strong>Host Port</strong>: The entry point on your actual laptop or server. This is the port you type into your browser or API client (e.g., <code>localhost:8080</code>).</p>
</li>
<li>
<p><strong>Container Port</strong>: The private internal port that your application is actually listening on inside its isolated environment.</p>
</li>
</ul>
<p>Consider a scenario where you are running a standard Nginx web server. Internally, the Nginx process naturally listens on port <code>80</code>. However, you might already have another service running on your machine using port <code>80</code>, or you simply want to access it via port <code>8080</code> locally.</p>
<p>Here is how you write that configuration cleanly:</p>
<pre><code class="language-yaml">version: &#x27;3.8&#x27;

services:
  web_server:
    image: nginx:latest
    container_name: reclear_web_app
    ports:
      - &quot;8080:80&quot; # Maps public Host Port 8080 to private Container Port 80
</code></pre>
<p>When you execute docker compose up with this file, Docker builds a bridge across your network boundary. If an external user navigates to <code>http://localhost:8080</code>, the Docker daemon catches that request and transparently hands it over to port <code>80</code> inside the <code>web_server</code> container.</p>
<p>Without this explicit ports definition, the application remains safely locked away inside the virtual network, completely inaccessible to your browser.</p>
<h2>The Expose Directive (Internal Documentation) Explained</h2>
<p>If the <code>ports</code> directive is a wide-open bridge to the outside world, the expose directive is an internal blueprint.</p>
<p>The biggest misunderstanding engineers have when starting out with Docker is assuming that expose opens ports to their host machine&#x27;s browser. It does not. If you configure <code>expose: - &quot;8080&quot;</code>, navigating to <code>localhost:8080</code> will result in a connection error.</p>
<p>Instead, the expose directive does two things:</p>
<ol>
<li>
<p><strong>It acts as documentation</strong>: It explicitly states which ports the containerized application is listening on at runtime for anyone reading your configuration file.</p>
</li>
<li>
<p><strong>It flags ports for internal networks</strong>: It informs the Docker daemon and specialized infrastructure tools (like reverse proxies or orchestration platforms) that this port is ready for container-to-container communication.</p>
</li>
</ol>
<p>The primary use case for <code>expose</code> is security through network isolation. In a multi-container stack, you rarely want every service accessible to the public internet.</p>
<p>For example, a database container needs to talk to your backend web application, but it should never be directly reachable by an external user. By using expose, you keep the database completely shielded from host-level networks while still allowing linked internal services to access it.</p>
<p>Let’s look at a secure, real-world multi-container setup. Here, we have an API backend container that needs to access a private MongoDB database.</p>
<pre><code class="language-yaml">version: &#x27;3.8&#x27;

services:
  web_api:
    image: node:18
    container_name: reclear_api
    ports:
      - &quot;3000:3000&quot; # Publicly accessible at http://localhost:3000

  database:
    image: mongo:latest
    container_name: reclear_db
    expose:
      - &quot;27017&quot; # Securely exposed ONLY to the web_api service
</code></pre>
<p>In this configuration, Docker creates a distinct separation of access:</p>
<ul>
<li>
<p><strong>The Public Rule</strong>: The <code>web_api</code> container uses <code>ports</code>. Traffic from the host machine on port <code>3000</code> safely maps directly inside the Node.js application.</p>
</li>
<li>
<p><strong>The Private Rule</strong>: The <code>database</code> container uses <code>expose</code>. The <code>web_api</code> container can connect seamlessly to MongoDB using the connection string <code>mongodb://reclear_db:27017</code>. However, if you try to connect your local desktop database client directly to <code>localhost:27017</code>, the connection will be blocked.</p>
</li>
</ul>
<p>Using <code>expose</code> here provides zero-overhead internal communication while preventing accidental public data exposure.</p>
<h2>Key Differences Between Ports and Expose</h2>
<p>The table below summarizes the key differences between <code>ports</code> and <code>expose</code>:</p>
<table><thead><tr><th>Feature / Metric</th><th><code>ports</code></th><th><code>expose</code></th><th>The Bottom Line</th></tr></thead><tbody><tr><td>Host Machine Access</td><td>Yes. Maps a host port directly to a container port.</td><td>No. Completely inaccessible from <code>localhost</code>.</td><td>Use <code>ports</code> if you need to open it in your web browser.</td></tr><tr><td>Container-to-Container Access</td><td>Yes. Accessible by other containers on the network.</td><td>Yes. Fully accessible by other containers on the network.</td><td>Both allow internal traffic; <code>ports</code> is redundant if you only need internal connectivity.</td></tr><tr><td>Public Internet Exposure</td><td>Yes. If your host machine has a public IP, the port is open to the world.</td><td>No. Safely isolated behind the default Docker network firewall.</td><td><code>expose</code> prevents accidental, insecure data leaks.</td></tr><tr><td>Primary Use Case</td><td>Web frontends, public APIs, and local development entrypoints.</td><td>Databases, internal microservices, and backend caches (Redis).</td><td>Keep the data layer private with <code>expose</code>; make the access layer public with <code>ports</code>.</td></tr><tr><td>Behavior without Definition</td><td>Container remains completely private from the host machine.</td><td>Internal containers can still talk to each other; <code>expose</code> just documents it.</td><td>Modern Docker networks allow internal traffic even if <code>expose</code> is completely omitted.</td></tr></tbody></table>
<h2>A Real-World Hands-On Walkthrough of Ports and Expose</h2>
<p>To solidify how <code>ports</code> and <code>expose</code> directives operate in a running environment, let’s deploy a standard two-tier application stack using Docker Compose. We will spin up a public-facing Nginx web frontend and a private backend PostgreSQL database to verify external access and internal isolation on your machine.</p>
<p>Save the following configuration as <code>docker-compose.yml</code>:</p>
<pre><code class="language-yaml">version: &#x27;3.8&#x27;

services:
  web:
    image: nginx:alpine
    container_name: reclear_frontend
    ports:
      - &quot;8080:80&quot;  # Opens access to the host machine
    depends_on:
      - db

  db:
    image: postgres:15-alpine
    container_name: reclear_backend
    expose:
      - &quot;5432&quot;  # Explicitly documents internal port access
    environment:
      POSTGRES_PASSWORD: secure_reclear_password
</code></pre>
<p><strong>Step1: launching the Infrastructure</strong></p>
<p>Start the application stack in detached mode by running the following command in your terminal:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<p><strong>Step 2: Validating External Host Access (<code>ports</code>)</strong></p>
<p>Open your local web browser and navigate to <code>http://localhost:8080</code>. You will instantly see the default Nginx welcome page.</p>
<p>This connection succeeds because the <code>ports</code> directive explicitly built a network bridge from your host machine&#x27;s port <code>8080</code> to the container&#x27;s internal port <code>80</code>.</p>
<p><strong>Step 3: Verifying Host Isolation (<code>expose</code>)</strong></p>
<p>Next, attempt to check if the PostgreSQL database is reachable from your host machine using a standard network ping tool like Netcat (<code>nc</code>) or a database client pointing to <code>localhost:5432</code>:</p>
<pre><code class="language-bash">nc -zv localhost 5432
</code></pre>
<p><strong>The Result:</strong> The connection will fail or time out. Even though the database container is actively running and listening on port <code>5432</code> internally, the <code>expose</code> directive prevents the host network from crossing the container boundary.</p>
<p><strong>Step 4: Testing Inter-Container Connectivity</strong></p>
<p>To prove that internal communication works perfectly across the <code>expose</code> boundary, we can jump inside the running frontend web container and check its connection to the database.</p>
<p>Execute a shell inside the frontend container:</p>
<pre><code class="language-bash">docker compose exec web sh
</code></pre>
<p>Once inside the container terminal, run a quick network check against the database service name on port <code>5432</code>:</p>
<pre><code class="language-bash">nc -zv db 5432
# db (172.21.0.2:5432) open
</code></pre>
<p><strong>The Result:</strong> You will receive a successful connection message (<code>open</code>). Because both services reside on the default Docker Compose network, the frontend has total access to the database via its documented internal port.</p>
<p>If you modify the <code>docker-compose.yml</code> file, entirely comment out or delete the lines for <code>expose: - &quot;5432&quot;</code>, and recreate the containers, running the internal test inside the web container will still succeed.</p>
<p>This proves a vital operational truth: modern Docker networks automatically allow unrestricted container-to-container communication on all ports. The <code>expose</code> directive is not a firewall toggle for internal traffic; it functions purely as an explicit, executable architectural blueprint for developers and automated proxy configurations.</p>
<h2>Common Mistakes &amp; Troubleshooting</h2>
<p>When working with Docker port configurations, things don&#x27;t always go according to plan. Here are the two most common pitfalls developers face and exactly how to resolve them.</p>
<ol>
<li><strong>Port Portals: Pinging &quot;Exposed&quot; Ports Successfully</strong></li>
</ol>
<p>A common point of confusion occurs when a developer tests an <code>expose</code>-only service from their host machine using a tool like Netcat (<code>nc</code>), only for the connection to unexpectedly succeed:</p>
<pre><code class="language-bash">nc -zv localhost 5432
# Connection to localhost port 5432 [tcp/postgresql] succeeded!
</code></pre>
<p>If the <code>expose</code> directive isolates the container from the host, why did the connection succeed?</p>
<p>The Root Cause: You have a port collision on your host machine. Another running container or a local background process (like a native PostgreSQL installation running on your laptop) has already mapped itself to host port <code>5432</code> using the <code>ports</code> directive.</p>
<p>When you check your active containers using <code>docker ps</code>, you will likely find a completely different container mapping traffic like this:</p>
<pre><code class="language-plaintext">0.0.0.0:5432-&gt;5432/tcp
</code></pre>
<p>Your <code>expose</code>-only container is still perfectly secure and isolated. The networking tool is simply reaching a completely different application that took ownership of that host port first. To fix this confusion, stop the conflicting project container using <code>docker stop [container_name]</code>.</p>
<ol start="2">
<li><strong>The Redundant Configuration Habit</strong></li>
</ol>
<p>Another frequent mistake found in beginner configuration files is defining a port in both the ports and expose arrays simultaneously:</p>
<pre><code class="language-yaml"># Avoid this redundant pattern
services:
  web:
    image: nginx:alpine
    expose:
      - &quot;80&quot;
    ports:
      - &quot;8080:80&quot;
</code></pre>
<p>The <code>ports</code> directive inherently opens the container to the host network and reveals the port internally to the Docker virtual network. Declaring the same container port inside the <code>expose</code> block adds absolutely no functional value or extra performance benefits. It simply clutters your configuration file. Keep your files clean: if you use ports, delete expose.</p>
<h2>Key Takeaways</h2>
<p>The <code>ports</code> directive is built specifically for external traffic, creating a direct bridge that maps a host machine port to an internal container port so you can access your applications via localhost. Conversely, the <code>expose</code> directive functions primarily as an operational blueprint, documenting internal container ports without opening any access doors to your laptop or the public internet. Because modern Docker networks automatically allow unrestricted container-to-container communication on the same virtual network, utilizing <code>expose</code> is entirely optional for internal data flow. To maintain production-grade security and avoid messy local port collisions, you should keep your sensitive data layers isolated behind default network boundaries and strictly use <code>ports</code> for your public-facing entry points.</p>]]></content:encoded>
      <pubDate>Sun, 24 May 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Olusegun Durojaye)</author>
      <category>DevOps</category>
      <category>Docker</category>
      <category>Containers</category>
      <category>CloudComputing</category>
      <enclosure url="https://reclear.io/blog/docker-ports-vs-expose/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>How to Automate Multi-Region Failover with AWS Lambda and Route 53</title>
      <link>https://reclear.io/blog/how-to-automate-multi-region-failover-aws-lambda-route53</link>
      <guid isPermaLink="true">https://reclear.io/blog/how-to-automate-multi-region-failover-aws-lambda-route53</guid>
      <description>Master resilient disaster recovery on AWS. Learn how to orchestrate Route 53 health checks and Lambda functions to eliminate manual failover bottlenecks.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/how-to-automate-multi-region-failover-aws-lambda-route53/cover/cover.webp" alt="How to Automate Multi-Region Failover with AWS Lambda and Route 53" /></p><p><strong>Modern enterprise applications require robust disaster recovery architectures that can automatically detect regional service degradation and route production traffic to healthy backup instances without manual intervention.</strong></p>
<aside><ul>
<li><strong>Eliminate manual DNS updates:</strong> Automate traffic redirection to standby regions in under 90 seconds.</li>
<li><strong>Implement deep health checks:</strong> Monitor critical system dependencies (DB, cache) rather than just simple network pings.</li>
<li><strong>Use Weighted Routing:</strong> Leverage Route 53 weighted record sets (100/0) for clean isolation of backup environments.</li>
<li><strong>Automate failback validation:</strong> Programmatically verify database replication lag before returning traffic to the primary region.</li>
<li><strong>Scale with confidence:</strong> Transition from a manual, high-risk operational posture to a deterministic, code-based execution loop.</li>
</ul></aside>
<p>Let&#x27;s delve into the world of Amazon Web Services (AWS) and explore how to design and implement a highly available, resilient cloud architecture that can withstand regional failures.</p>
<p>This technical strategy guide will walk you through how to leverage Amazon Route 53 health checks and AWS Lambda to orchestrate resilient cross-region failover systems during critical outages. With this technical knowledge, you will have full understanding and mastery of integrating CloudWatch alarms with Route 53 weighted records to eliminate single points of failure in your serverless software application architectures.</p>
<h2>1. The Day 2 Operations Crisis: When the Enabler Becomes the Bottleneck</h2>
<p><strong>Consider the contrasting experiences of two separate engineering teams when a major cloud data center suffers an unexpected fiber cut during peak user traffic periods on their platform.</strong></p>
<p>Imagine two SaaS companies, both with global customer bases. Suddenly, an earthquake in the US-East region takes down the primary data center for both companies. Let&#x27;s see what happens next:</p>
<ul>
<li><strong>The First Team</strong>: Relies on manual DNS record modifications and basic ping checks, resulting in a three-hour recovery window marked by severe client connection dropouts. Their operations dashboard fills with critical alerts while exhausted on-call engineers scramble to determine the exact healthy endpoints in alternative geographical regions under intense pressure.</li>
<li><strong>The Second Team</strong>: Implements an automated failover system using Route 53 health checks and Lambda, transitioning their global user base seamlessly within ninety seconds. Active-passive weighted record sets automatically detect the main cluster failure, which triggers an automated serverless script to execute necessary system validation checks before rerouting traffic.</li>
</ul>
<p>This scenario illustrates a critical point: in today&#x27;s fast-paced digital landscape, the difference between a manual failover process and an automated one can be the difference between a minor inconvenience and a catastrophic business failure.</p>
<aside><p>A resilient cross-region failover architecture is designed to protect active user sessions during large-scale cloud infrastructure failures by establishing automated DNS validation loops.</p></aside>
<h2>2. Thesis: The &quot;DIY Active-Passive Routing&quot; Hallucination</h2>
<p><strong>For much of the early cloud era, configuring custom routing scripts on private virtual servers was viewed as the definitive hallmark of a highly mature enterprise infrastructure.</strong> Organizations believed that by maintaining complete control over their DNS resolution logic, they could optimize performance and control traffic distribution better than standardized public cloud platforms.</p>
<p><strong>This badge of honor often came at the hidden cost of undifferentiated operational heavy lifting.</strong> Bespoke routing scripts required continuous maintenance, manual patching of security vulnerabilities, and constant adjustments to accommodate the changing network properties of distributed cloud provider data centers. Consequently, the legacy manual routing architecture ultimately created significant operational debt, transforming a key competitive advantage into a major point of systemic fragility during critical events.</p>
<h2>3. Pain Point Analysis: Why Traditional DNS Failover Fails</h2>
<p><strong>The current industry standard of relying on standard DNS TTL values and manual routing changes is failing to meet the demands of modern cloud-native applications.</strong> Traditional solutions are unable to handle transient network blips gracefully, causing unnecessary routing changes that introduce severe connection instability across your global user base.</p>
<table><thead><tr><th style="text-align:left">Feature</th><th style="text-align:left">Legacy Manual Failover</th><th style="text-align:left">Automated Route 53 Failover</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Recovery Window</strong></td><td style="text-align:left">Extended MTTR (hours)</td><td style="text-align:left">Minimal MTTR (seconds)</td></tr><tr><td style="text-align:left"><strong>Execution</strong></td><td style="text-align:left">High risk of human error</td><td style="text-align:left">Deterministic code-based execution</td></tr><tr><td style="text-align:left"><strong>Monitoring</strong></td><td style="text-align:left">Simple ping-based checks</td><td style="text-align:left">Deep dependency health checks</td></tr><tr><td style="text-align:left"><strong>Failback</strong></td><td style="text-align:left">Manual verification</td><td style="text-align:left">Automated data consistency gates</td></tr><tr><td style="text-align:left"><strong>Audit Trail</strong></td><td style="text-align:left">Fragmented manual logs</td><td style="text-align:left">Declarative IaC verification logs</td></tr></tbody></table>
<p><strong>Unmanaged network outages trigger severe active client connection dropouts and expensive service level agreement (SLA) penalties.</strong> Traditional configurations lack the programmatic intelligence to validate that a backup region is actually healthy and fully scaled before redirecting massive user traffic volumes. As a result, the failover destination frequently becomes immediately overwhelmed, leading to cascading cluster failures.</p>
<h2>4. Synthesis: The Rise of Managed Platform Engineering</h2>
<p><strong>Managed Platform Engineering resolves the disaster recovery paradox by delivering standardized, automated infrastructure abstractions.</strong> This operational discipline treats disaster recovery as a built-in, automated product feature rather than a complex manual procedure. By integrating Route 53 and serverless execution pipelines, organizations achieve continuous resilience while maintaining high deployment velocity.</p>
<p><strong>The automated failover loop represents the modern architectural standard for cloud enterprise systems.</strong> These managed &quot;paved roads&quot; combine Route 53 health checking, CloudWatch metric alerts, and Lambda scripts to form a self-healing infrastructure fabric. Consequently, your platform gains the capacity to automatically route around regional failures, preserving client access while developers focus on building new product features.</p>
<h2>5. Prerequisites: Establishing Your High-Availability Baselines</h2>
<p>Before deploying this automated regional failover blueprint, ensure that the following core structural prerequisites are fully established:</p>
<div><h3>Infrastructure Tooling</h3><p>Configure your local environment with the following versions to ensure compatibility with our IaC blueprints:</p><ul>
<li><strong>Terraform CLI (v1.5.0+)</strong>: To execute the declarative configuration blocks for active-passive deployment.</li>
<li><strong>AWS Boto3 SDK (v1.28+)</strong>: Required for the Lambda runtime to modify Route 53 resource weights dynamically.</li>
</ul><h3>AWS Permissions</h3><p>Configure a custom IAM policy with explicit write permissions for the following actions:</p><ul>
<li><code>route53:ChangeResourceRecordSets</code></li>
<li><code>route53:GetHealthCheck</code></li>
<li><code>cloudwatch:SetAlarmState</code></li>
</ul><h3>Network Assets</h3><ul>
<li>A registered domain name hosted in a <strong>Route 53 Public Hosted Zone</strong>.</li>
<li>Active Application Load Balancers (ALB) or API Gateway endpoints in at least two separate AWS regions (e.g., <code>us-east-1</code> and <code>us-west-2</code>).</li>
</ul></div>
<h2>6. The Conceptual Framework: Designing the Resiliency Loop</h2>
<p>To implement a highly reliable multi-region disaster recovery system, you must understand the three primary conceptual pillars that govern automated active-passive routing.</p>
<aside><p><strong>CRITICAL WARNING</strong>: Failing to validate database replication consistency before executing automated failback procedures can result in catastrophic data loss across your active production database cluster.</p></aside>
<ol>
<li><strong>Continuous Endpoint Health Verification</strong>: Route 53 health checks must evaluate specific, lightweight endpoints (e.g., <code>/healthz</code>) that expose the state of critical backend dependencies (DB, cache, external APIs).</li>
<li><strong>Weighted Routing Policies</strong>: In standard conditions, primary records receive a weight of <strong>100</strong>, while secondary records receive a weight of <strong>0</strong>. This isolates the backup environment from production traffic until failover is required.</li>
<li><strong>Automated Failback Orchestration</strong>: Public traffic must only return to the primary region after database replication lag is verified as resolved. This programmatic gate prevents transaction discrepancies between regional clusters.</li>
</ol>
<h3>The Automated Failover Flow</h3>
<p><img src="https://reclear.io/blog/how-to-automate-multi-region-failover-aws-lambda-route53/diagrams/automated-failover-flow.webp" alt="Automated failover flow: primary region outage triggers CloudWatch alarm, SNS notifies Lambda, Lambda swaps Route 53 weights, traffic reroutes to standby."/></p>
<h2>7. Step-by-Step Implementation Roadmap</h2>
<div><h3>Step 1: Configuring Route 53 Weighted Record Sets</h3><p>Establish core DNS weighted record sets for your application endpoints. Configure two identical alias records sharing the same name but with distinct <code>SetIdentifiers</code> (e.g., <code>primary-us-east-1</code> and <code>secondary-us-west-2</code>).</p><p>Assign the primary set a weight of <strong>100</strong> and the secondary set a weight of <strong>0</strong>. This ensures all initial user connections route to your primary region.</p><h3>Step 2: Provisioning CloudWatch Metric Alarms</h3><p>Configure custom CloudWatch alarms to monitor connection latency and HTTP 5xx error rates. Set a threshold (e.g., &gt;5% errors over 2 minutes) to trigger an SNS topic that forwards the payload to our Lambda failover pipeline.</p><h3>Step 3: Deploying the AWS Lambda Failover Handler</h3><p>Deploy a Lambda function that parses the SNS alert and initiates a <code>ChangeResourceRecordSets</code> request using the Boto3 SDK. This script atomically swaps the weights (Primary 100 -&gt; 0, Secondary 0 -&gt; 100).</p><h3>Step 4: Integrating Application Recovery Controller (ARC)</h3><p>Integrate Amazon Route 53 Application Recovery Controller to provide precise, out-of-band manual routing control switches. This acts as a circuit breaker that functions even during total regional control plane outages.</p></div>
<h2>8. System Blueprint: IaC &amp; Automation</h2>
<h3>Infrastructure-as-Code (Terraform)</h3>
<p>This production-ready Terraform configuration defines the primary and secondary Route 53 resource record sets, attaching their unique identifiers and linking them to regional health checks.</p>
<pre><code class="language-hcl"># AWS Route 53 health check for the primary region
resource &quot;aws_route53_health_check&quot; &quot;primary_region&quot; {
  fqdn              = &quot;primary-api.example.com&quot;
  port              = 443
  type              = &quot;HTTPS&quot;
  resource_path     = &quot;/healthz&quot;
  failure_threshold = &quot;3&quot;
  request_interval  = &quot;30&quot;
  
  tags = {
    Environment = &quot;production&quot;
    Application = &quot;serverless-failover&quot;
  }
}

# Primary Record: Active (Weight 100)
resource &quot;aws_route53_record&quot; &quot;primary_endpoint&quot; {
  zone_id = var.hosted_zone_id
  name    = &quot;api.example.com&quot;
  type    = &quot;CNAME&quot;
  ttl     = &quot;60&quot;

  weighted_routing_policy {
    weight = 100
  }

  set_identifier = &quot;primary-us-east-1&quot;
  records        = [&quot;primary-api.example.com&quot;]
  health_check_id = aws_route53_health_check.primary_region.id
}

# Secondary Record: Passive (Weight 0)
resource &quot;aws_route53_record&quot; &quot;secondary_endpoint&quot; {
  zone_id = var.hosted_zone_id
  name    = &quot;api.example.com&quot;
  type    = &quot;CNAME&quot;
  ttl     = &quot;60&quot;

  weighted_routing_policy {
    weight = 0
  }

  set_identifier = &quot;secondary-us-west-2&quot;
  records        = [&quot;secondary-api.example.com&quot;]
}
</code></pre>
<h3>Serverless Failover Handler (Python)</h3>
<p>The following Python script executes inside AWS Lambda to update record weights programmatically. It uses atomic updates to ensure there is never a period where both regions are inactive.</p>
<pre><code class="language-python">import os
import boto3

def lambda_handler(event, context):
    &quot;&quot;&quot;
    Automates Route53 weighted record failover.
    Triggered by CloudWatch alarms via SNS.
    &quot;&quot;&quot;
    client = boto3.client(&#x27;route53&#x27;)
    hosted_zone_id = os.environ[&#x27;HOSTED_ZONE_ID&#x27;]
    record_set_name = os.environ[&#x27;RECORD_SET_NAME&#x27;]
    
    # Atomic Change: Update both weights in a single transaction
    response = client.change_resource_record_sets(
        HostedZoneId=hosted_zone_id,
        ChangeBatch={
            &#x27;Comment&#x27;: &#x27;Automated disaster recovery routing change&#x27;,
            &#x27;Changes&#x27;: [
                {
                    &#x27;Action&#x27;: &#x27;UPSERT&#x27;,
                    &#x27;ResourceRecordSet&#x27;: {
                        &#x27;Name&#x27;: record_set_name,
                        &#x27;Type&#x27;: &#x27;CNAME&#x27;,
                        &#x27;SetIdentifier&#x27;: &#x27;primary-us-east-1&#x27;,
                        &#x27;Weight&#x27;: 0,
                        &#x27;TTL&#x27;: 60,
                        &#x27;ResourceRecords&#x27;: [{&#x27;Value&#x27;: &#x27;primary-api.example.com&#x27;}]
                    }
                },
                {
                    &#x27;Action&#x27;: &#x27;UPSERT&#x27;,
                    &#x27;ResourceRecordSet&#x27;: {
                        &#x27;Name&#x27;: record_set_name,
                        &#x27;Type&#x27;: &#x27;CNAME&#x27;,
                        &#x27;SetIdentifier&#x27;: &#x27;secondary-us-west-2&#x27;,
                        &#x27;Weight&#x27;: 100,
                        &#x27;TTL&#x27;: 60,
                        &#x27;ResourceRecords&#x27;: [{&#x27;Value&#x27;: &#x27;secondary-api.example.com&#x27;}]
                    }
                }
            ]
        }
    )
    return response
</code></pre>
<h2>9. Validation: Simulating Disaster Under Control</h2>
<p>To verify the system, trigger the primary region CloudWatch alarm programmatically and run a DNS query to confirm that the CNAME updates instantly.</p>
<pre></pre>
<pre></pre>
<h2>10. Boardroom Translation: Demonstrating Business Value</h2>
<p>To secure long-term executive investment, translate these technical achievements into business performance outcomes.</p>
<table><thead><tr><th style="text-align:left">Metric</th><th style="text-align:left">Legacy Manual Posture</th><th style="text-align:left">Automated Route 53 Posture</th></tr></thead><tbody><tr><td style="text-align:left"><strong>Mean Time to Resolution</strong></td><td style="text-align:left">MTTR up to 4 hours due to manual validation.</td><td style="text-align:left">Reroute traffic globally in <strong>less than 90 seconds</strong>.</td></tr><tr><td style="text-align:left"><strong>Compliance Velocity</strong></td><td style="text-align:left">High manual collection effort for SOC2/ISO audits.</td><td style="text-align:left">Declarative IaC logs for <strong>automated audits</strong>.</td></tr><tr><td style="text-align:left"><strong>Revenue Protection</strong></td><td style="text-align:left">SLA penalties and direct loss of subscription revenue.</td><td style="text-align:left"><strong>Zero connection dropouts</strong> during regional outages.</td></tr><tr><td style="text-align:left"><strong>Operational Overhead</strong></td><td style="text-align:left">Exhausting on-call pressure and human error risk.</td><td style="text-align:left"><strong>Self-healing infrastructure</strong> with zero manual steps.</td></tr></tbody></table>
<h2>Conclusion: The Path Forward</h2>
<p>Transitioning from a legacy manual disaster recovery strategy to an automated, serverless cross-region routing model is an essential requirement for modern enterprise software platforms. By combining Route 53 health checking systems with dynamic AWS Lambda handlers, your architecture can resolve critical network degradation issues without human intervention under pressure.</p>
<p>At <a href="https://reclear.io">Reclear</a>, we help high-growth SaaS companies build and document resilient infrastructure that scales. We specialize in turning complex cloud architectures into clear, practical documentation that developers and operations teams can trust.</p>
<p>If you are looking to improve your disaster recovery posture or need help documenting your cloud infrastructure, <a href="https://reclear.io/contact">reach out to us</a> to see how we can help.</p>
<p><a href="https://reclear.io/book-a-call">Work with us</a>: Book a call to discuss how we can help you build and document production-ready multi-region architectures.</p>]]></content:encoded>
      <pubDate>Sun, 10 May 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Abioye Oyatoye)</author>
      <category>Tutorials</category>
      <category>AWS</category>
      <category>route53</category>
      <category>lambda</category>
      <enclosure url="https://reclear.io/blog/how-to-automate-multi-region-failover-aws-lambda-route53/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>API Error Handling Best Practices for Developers</title>
      <link>https://reclear.io/blog/api-error-handling-best-practices</link>
      <guid isPermaLink="true">https://reclear.io/blog/api-error-handling-best-practices</guid>
      <description>Learn API error handling best practices with a reusable Node.js/Express pattern that reduces integration time, support load, and developer frustration.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/api-error-handling-best-practices/cover/cover.webp" alt="API Error Handling Best Practices for Developers" /></p><aside><p>Most API errors are designed from the server&#x27;s perspective: they capture what went wrong internally, not what a developer needs to know externally. A well-designed error response answers four questions: what happened, what kind of thing happened, whose fault it is, and what the developer should do next. This article establishes a consistent error schema, implements it as a reusable Express middleware, covers the status code decisions where teams consistently get it wrong, and shows the multi-field validation pattern that every tutorial skips.</p><p>The <a href="https://reclear.io/blog/what-is-idempotency-in-rest-api">idempotency article</a> and <a href="https://reclear.io/blog/webhook-handlers-best-practices-retry-patterns">webhook article</a> in this series showed error codes in context. This article explains the design decisions behind them. If you&#x27;ve shipped the <a href="https://reclear.io/blog/rate-limiting-algorithms-code-examples">rate limiting middleware from the previous article</a>, the 429 handling here connects directly to that.</p></aside>
<p>When an API returns a well-structured error, a developer can diagnose and fix the problem in seconds. When it returns <code>{&quot;error&quot;: true, &quot;message&quot;: &quot;Something went wrong&quot;}</code>, they spend the next hour on GitHub issues. This article defines the four-question error schema, implements it as reusable Express middleware, covers the status code decisions teams most often get wrong, and shows the multi-field validation pattern that prevents unnecessary round trips.</p>
<h2>Two APIs, identical features, different integration times</h2>
<p>A developer is choosing between two payment APIs. Same feature set. Similar pricing. Similar documentation coverage. They start integrating both in parallel.</p>
<p>The first API returns structured errors on every failure: a machine-readable <code>code</code>, a human-readable <code>message</code>, the specific field that failed, and a <code>doc_url</code> linking to the exact error in the documentation. When something breaks, the developer knows immediately what happened, whether it was their fault, and where to read more.</p>
<p>The second API returns HTTP status codes and a string message. The strings are inconsistent across endpoints. Some failures return 400, others return 500 for the same category of error. One endpoint returns <code>{&quot;error&quot;: true, &quot;message&quot;: &quot;Something went wrong&quot;}</code>. The error isn&#x27;t in the documentation. There&#x27;s a GitHub issue from six months ago where another developer figured out it&#x27;s a validation error on a field that isn&#x27;t in the request schema docs.</p>
<p>The first API takes two hours to integrate. The second takes most of a day because it is not a worse API, but every failure mode has to be discovered in production.</p>
<p>That difference is entirely a design choice. Error design is the part of API DX that controls integration time most directly, and most teams make it while thinking about the server, not the developer on the other side.</p>
<aside><p><strong>What you&#x27;ll need:</strong> Node.js 18+, Express 4+ or 5+, and TypeScript configured for your API codebase.</p></aside>
<h2>What bad error design looks like</h2>
<p>Bad error design has two failure modes, and they&#x27;re different problems.</p>
<p><strong>The answer-nothing error:</strong></p>
<p>The most common form looks like this:</p>
<pre><code class="language-typescript">// What most APIs return on failure
{ &quot;error&quot;: true, &quot;message&quot;: &quot;Something went wrong&quot; }
</code></pre>
<p>A developer receiving this cannot tell whether the error is retryable, which field failed, or whose fault it is. The error answers none of the questions needed to keep moving.</p>
<p><strong>The inconsistent-schema problem:</strong></p>
<p>This is quieter but causes more damage at scale. Same status code, three different shapes:</p>
<pre><code class="language-typescript">// Endpoint A - user service
{ &quot;error&quot;: &quot;not_found&quot; }

// Endpoint B - payments service
{ &quot;status&quot;: 404, &quot;msg&quot;: &quot;Resource does not exist&quot; }

// Endpoint C - notifications service
{ &quot;code&quot;: 404, &quot;description&quot;: &quot;Not Found&quot; }
</code></pre>
<p>An integration developer now needs three separate error-handling branches for what should be one failure class. As endpoints grow, complexity and support load grow with them.</p>
<h2>The four questions every error must answer</h2>
<p>A well-designed error response answers exactly four questions. Every field in the schema maps to one of them.</p>
<p><strong>1. What happened?</strong> A human-readable <code>message</code> that a developer can read and understand without opening the documentation. Written in plain English, not internal system language.</p>
<p><strong>2. What kind of thing happened?</strong> A machine-readable <code>code</code> in <code>snake_case</code>. This is what developers use in their error handling logic, such as <code>if (error.code === &#x27;validation_error&#x27;)</code>, without parsing strings.</p>
<p><strong>3. Whose fault is it?</strong> The HTTP status code carries this signal. 4xx means the caller did something wrong. 5xx means the server is at fault. This determines whether the developer fixes their code or waits for the API to recover.</p>
<p><strong>4. What should the developer do next?</strong> A <code>doc_url</code> linking to the specific error in the documentation. Stripe has shipped this as a standard field for years. It is the difference between a developer spending thirty seconds reading the right page and spending thirty minutes on a GitHub issue.</p>
<p>Here&#x27;s the before and after for a validation failure:</p>
<pre><code class="language-typescript">// Before: answers nothing
{ &quot;error&quot;: true, &quot;message&quot;: &quot;Invalid request&quot; }

// After: answers all four questions
{
  &quot;error&quot;: {
    &quot;code&quot;: &quot;validation_error&quot;,       // what kind of thing happened
    &quot;message&quot;: &quot;The &#x27;amount&#x27; field must be a positive integer.&quot;,  // what happened
    &quot;field&quot;: &quot;amount&quot;,               // specific context
    &quot;doc_url&quot;: &quot;https://api.example.com/docs/errors#validation_error&quot;  // what to do next
  }
  // HTTP 422 carries: whose fault it is
}
</code></pre>
<p>The status code answers question three. The body answers the other three. That&#x27;s the complete contract.</p>
<h2>The error schema</h2>
<p><strong>Step 1: Define the error contract and factory</strong></p>
<p>Create a shared <code>lib/errors.ts</code> module that defines the response shape and a factory function for constructing it:</p>
<pre><code class="language-typescript">interface ApiErrorBody {
  error: {
    code: string;
    message: string;
    field?: string;
    fields?: Array&lt;{ field: string; message: string }&gt;;
    doc_url?: string;
  };
}

interface ApiErrorResponse {
  status: number;
  body: ApiErrorBody;
}

export function apiError({
  code,
  message,
  field,
  fields,
  docUrl,
  status,
}: {
  code: string;
  message: string;
  field?: string;
  fields?: Array&lt;{ field: string; message: string }&gt;;
  docUrl?: string;
  status: number;
}): ApiErrorResponse {
  return {
    status,
    body: {
      error: {
        code,
        message,
        ...(field &amp;&amp; { field }),
        ...(fields &amp;&amp; fields.length &gt; 0 &amp;&amp; { fields }),
        ...(docUrl &amp;&amp; { doc_url: docUrl }),
      },
    },
  };
}

export const Errors = {
  notFound: (resource: string) =&gt;
    apiError({
      code: &quot;not_found&quot;,
      message: `The requested ${resource} could not be found.`,
      status: 404,
    }),

  unauthorized: () =&gt;
    apiError({
      code: &quot;unauthorized&quot;,
      message: &quot;Authentication is required to access this resource.&quot;,
      status: 401,
    }),

  forbidden: () =&gt;
    apiError({
      code: &quot;forbidden&quot;,
      message: &quot;You do not have permission to perform this action.&quot;,
      status: 403,
    }),

  rateLimitExceeded: (retryAfter: number) =&gt;
    apiError({
      code: &quot;rate_limit_exceeded&quot;,
      message: `Too many requests. Please retry after ${retryAfter} seconds.`,
      status: 429,
    }),

  internal: () =&gt;
    apiError({
      code: &quot;internal_error&quot;,
      message: &quot;An unexpected error occurred. Our team has been notified.&quot;,
      status: 500,
    }),
} as const;
</code></pre>
<p><strong>Step 2: Create the error handler middleware</strong></p>
<p>Extend the base <code>Error</code> class so application code can throw structured errors that the middleware catches. Register this handler last in the Express chain.</p>
<pre><code class="language-typescript">import type { Request, Response, NextFunction, ErrorRequestHandler } from &quot;express&quot;;
import { apiError, Errors } from &quot;../lib/errors&quot;;

export class ApiError extends Error {
  public readonly status: number;
  public readonly body: ReturnType&lt;typeof apiError&gt;[&quot;body&quot;];
  public readonly isApiError = true;

  constructor(response: ReturnType&lt;typeof apiError&gt;) {
    super(response.body.error.message);
    this.status = response.status;
    this.body = response.body;
  }
}

export const errorHandler: ErrorRequestHandler = (
  err: unknown,
  req: Request,
  res: Response,
  // next must be declared even if unused; Express requires all four params
  _next: NextFunction
) =&gt; {
  if (err instanceof ApiError) {
    return res.status(err.status).json(err.body);
  }

  // Log the real cause server-side; return nothing useful externally.
  // Never leak stack traces, database messages, internal IDs, or file paths.
  console.error({
    message: &quot;Unhandled error&quot;,
    error: err instanceof Error ? err.message : String(err),
    stack: err instanceof Error ? err.stack : undefined,
    path: req.path,
    method: req.method,
  });

  return res.status(500).json(Errors.internal().body);
};
</code></pre>
<p><strong>Step 3: Register the middleware</strong></p>
<p>Add the handler after all routes so Express routes errors through it:</p>
<pre><code class="language-typescript">import express from &quot;express&quot;;
import { errorHandler } from &quot;./middleware/error-handler&quot;;
import { paymentsRouter } from &quot;./routes/payments&quot;;
import { usersRouter } from &quot;./routes/users&quot;;

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

app.use(&quot;/api/payments&quot;, paymentsRouter);
app.use(&quot;/api/users&quot;, usersRouter);

// Error handler must be registered after all routes
app.use(errorHandler);
</code></pre>
<p><strong>Step 4: Throw structured errors from routes</strong></p>
<p>Any route handler can now throw an <code>ApiError</code> directly. The middleware catches it and sends the structured response:</p>
<pre><code class="language-typescript">import { Router } from &quot;express&quot;;
import { ApiError } from &quot;../middleware/error-handler&quot;;
import { apiError } from &quot;../lib/errors&quot;;

const router = Router();

router.post(&quot;/charge&quot;, async (req, res, next) =&gt; {
  try {
    const { amount, currency } = req.body;

    if (!amount || typeof amount !== &quot;number&quot; || amount &lt;= 0) {
      throw new ApiError(
        apiError({
          code: &quot;validation_error&quot;,
          message: &quot;The &#x27;amount&#x27; field must be a positive integer.&quot;,
          field: &quot;amount&quot;,
          docUrl: &quot;https://api.example.com/docs/errors#validation_error&quot;,
          status: 422,
        })
      );
    }

    // ... process payment
  } catch (err) {
    next(err); // passes to errorHandler
  }
});

export { router as paymentsRouter };
</code></pre>
<aside><p>Never put <code>err.stack</code>, database error messages, internal identifiers, or file paths in a 500 response body. Log them. Return only the stable, generic <code>internal_error</code> response. A leaked stack trace tells an attacker your framework, your file structure, and which dependencies you&#x27;re running. OWASP A10:2025 names this exact pattern as a top-ten API security failure.</p></aside>
<h2>Validation errors: the multi-field case</h2>
<p>Single-field errors are straightforward. The schema handles them with the <code>field</code> property. The more important case is a request that fails validation on multiple fields simultaneously.</p>
<p>Most APIs return only the first validation error they encounter. The developer fixes <code>amount</code>, submits again, gets a new error on <code>currency</code>, fixes that, submits again, gets a third error on <code>idempotency_key</code>. Each round trip is friction that compounds across the integration.</p>
<p>Return all validation errors in a single response:</p>
<pre><code class="language-typescript">import { ApiError } from &quot;../middleware/error-handler&quot;;
import { apiError } from &quot;./errors&quot;;

interface ValidationRule {
  field: string;
  value: unknown;
  validate: (v: unknown) =&gt; boolean;
  message: string;
}

export function validateRequest(rules: ValidationRule[]): void {
  const failures = rules
    .filter((rule) =&gt; !rule.validate(rule.value))
    .map(({ field, message }) =&gt; ({ field, message }));

  if (failures.length === 0) return;

  throw new ApiError(
    apiError({
      code: &quot;validation_error&quot;,
      message: &quot;The request contains invalid fields.&quot;,
      fields: failures,
      docUrl: &quot;https://api.example.com/docs/errors#validation_error&quot;,
      status: 422,
    })
  );
}
</code></pre>
<p>Call it from any route handler by passing a list of field rules:</p>
<pre><code class="language-typescript">// Usage in a route handler
validateRequest([
  {
    field: &quot;amount&quot;,
    value: req.body.amount,
    validate: (v) =&gt; typeof v === &quot;number&quot; &amp;&amp; v &gt; 0,
    message: &quot;Must be a positive integer.&quot;,
  },
  {
    field: &quot;currency&quot;,
    value: req.body.currency,
    validate: (v) =&gt; typeof v === &quot;string&quot; &amp;&amp; /^[A-Z]{3}$/.test(v as string),
    message: &quot;Must be a valid ISO 4217 currency code.&quot;,
  },
  {
    field: &quot;idempotency_key&quot;,
    value: req.headers[&quot;idempotency-key&quot;],
    validate: (v) =&gt; typeof v === &quot;string&quot; &amp;&amp; v.length &gt;= 16,
    message: &quot;Must be a string of at least 16 characters.&quot;,
  },
]);
</code></pre>
<p>The response the developer receives:</p>
<pre><code class="language-json">{
  &quot;error&quot;: {
    &quot;code&quot;: &quot;validation_error&quot;,
    &quot;message&quot;: &quot;The request contains invalid fields.&quot;,
    &quot;fields&quot;: [
      { &quot;field&quot;: &quot;amount&quot;, &quot;message&quot;: &quot;Must be a positive integer.&quot; },
      { &quot;field&quot;: &quot;currency&quot;, &quot;message&quot;: &quot;Must be a valid ISO 4217 currency code.&quot; },
      { &quot;field&quot;: &quot;idempotency_key&quot;, &quot;message&quot;: &quot;Must be a string of at least 16 characters.&quot; }
    ],
    &quot;doc_url&quot;: &quot;https://api.example.com/docs/errors#validation_error&quot;
  }
}
</code></pre>
<p>One response. Three problems fixed. Zero additional round trips.</p>
<h2>Status code decisions that matter</h2>
<p>Most articles list all status codes. What matters in practice is five decisions teams consistently get wrong.</p>
<table><thead><tr><th style="text-align:left">Decision</th><th style="text-align:left">Correct use</th><th style="text-align:left">Common mistake</th></tr></thead><tbody><tr><td style="text-align:left">400 vs 422</td><td style="text-align:left">400 for parsing failures (malformed JSON, bad content-type). 422 for semantic failures (valid JSON, failed business rules).</td><td style="text-align:left">Using 400 for everything.</td></tr><tr><td style="text-align:left">401 vs 403</td><td style="text-align:left">401 when identity is missing or invalid. 403 when identity is known but not permitted.</td><td style="text-align:left">Using 401 for permission failures; sends developers to fix the wrong thing.</td></tr><tr><td style="text-align:left">404 vs 403 on private resources</td><td style="text-align:left">403 is accurate but leaks that the resource exists. 404 hides existence and reduces enumeration risk.</td><td style="text-align:left">Always returning 403 on private resources.</td></tr><tr><td style="text-align:left">503 vs 500</td><td style="text-align:left">503 for transient outages where retry is expected; include <code>Retry-After</code>. 500 for actual server failures.</td><td style="text-align:left">Using 500 for everything, which signals &quot;don&#x27;t retry&quot; when retry is appropriate.</td></tr></tbody></table>
<p><strong>429 and <code>Retry-After</code>.</strong> A 429 without <code>Retry-After</code> gives clients no reliable backoff target. Always include it as integer seconds, not an HTTP date. This is what modern clients expect and is resilient to clock skew.</p>
<pre><code class="language-typescript">// In your rate limiting middleware
if (!result.allowed) {
  const retryAfter = result.resetAt - Math.floor(Date.now() / 1000);
  res.setHeader(&quot;Retry-After&quot;, retryAfter); // integer seconds, not HTTP date
  res.setHeader(&quot;X-RateLimit-Limit&quot;, limit);
  res.setHeader(&quot;X-RateLimit-Remaining&quot;, 0);
  res.setHeader(&quot;X-RateLimit-Reset&quot;, result.resetAt);

  throw new ApiError(Errors.rateLimitExceeded(retryAfter));
}
</code></pre>
<h2>Machine-readable error codes</h2>
<p>HTTP status codes are coarse. 400 covers dozens of different failure modes. Machine-readable error codes let integration developers handle specific cases without parsing human-readable strings.</p>
<p>Design principles that make codes usable:</p>
<ul>
<li><strong><code>snake_case</code> strings, not integers.</strong> <code>validation_error</code> is self-documenting in a log, a Sentry trace, or a client-side error handler. <code>4001</code> is not.</li>
<li><strong>Namespace by domain.</strong> <code>payment_method_declined</code> is unambiguous. <code>declined</code> is not.</li>
<li><strong>Be specific without being verbose.</strong> <code>card_insufficient_funds</code> is right. <code>the_card_was_declined_due_to_insufficient_funds</code> is not.</li>
</ul>
<p>The exercise of defining your error code catalog forces you to enumerate every failure mode before they&#x27;re discovered in production. That list becomes your error documentation, your QA checklist, and your on-call runbook simultaneously.</p>
<section><h3>Example error code catalog: payments API</h3><table><thead><tr><th style="text-align:left">Code</th><th style="text-align:left">Status</th><th style="text-align:left">Description</th></tr></thead><tbody><tr><td style="text-align:left"><code>validation_error</code></td><td style="text-align:left">422</td><td style="text-align:left">One or more request fields failed validation. See <code>fields</code> for specifics.</td></tr><tr><td style="text-align:left"><code>idempotency_key_reuse</code></td><td style="text-align:left">422</td><td style="text-align:left">The idempotency key has already been used with different request parameters.</td></tr><tr><td style="text-align:left"><code>payment_method_declined</code></td><td style="text-align:left">402</td><td style="text-align:left">The payment method was declined by the issuer.</td></tr><tr><td style="text-align:left"><code>card_insufficient_funds</code></td><td style="text-align:left">402</td><td style="text-align:left">The card has insufficient funds for this transaction.</td></tr><tr><td style="text-align:left"><code>card_expired</code></td><td style="text-align:left">422</td><td style="text-align:left">The card&#x27;s expiration date has passed.</td></tr><tr><td style="text-align:left"><code>card_invalid_cvv</code></td><td style="text-align:left">422</td><td style="text-align:left">The CVV provided does not match the card.</td></tr><tr><td style="text-align:left"><code>currency_not_supported</code></td><td style="text-align:left">422</td><td style="text-align:left">The requested currency is not supported in this region.</td></tr><tr><td style="text-align:left"><code>amount_below_minimum</code></td><td style="text-align:left">422</td><td style="text-align:left">The amount is below the minimum transaction threshold.</td></tr><tr><td style="text-align:left"><code>amount_above_maximum</code></td><td style="text-align:left">422</td><td style="text-align:left">The amount exceeds the maximum transaction threshold.</td></tr><tr><td style="text-align:left"><code>rate_limit_exceeded</code></td><td style="text-align:left">429</td><td style="text-align:left">Too many requests. Check <code>Retry-After</code> for the reset window.</td></tr><tr><td style="text-align:left"><code>not_found</code></td><td style="text-align:left">404</td><td style="text-align:left">The requested resource could not be found.</td></tr><tr><td style="text-align:left"><code>unauthorized</code></td><td style="text-align:left">401</td><td style="text-align:left">Authentication credentials are missing or invalid.</td></tr><tr><td style="text-align:left"><code>forbidden</code></td><td style="text-align:left">403</td><td style="text-align:left">The authenticated user does not have permission for this action.</td></tr><tr><td style="text-align:left"><code>internal_error</code></td><td style="text-align:left">500</td><td style="text-align:left">An unexpected server error occurred. The team has been notified.</td></tr></tbody></table></section>
<h2>What changes for the developer</h2>
<p>With this schema, the second API from the opening now returns:</p>
<pre><code class="language-json">{
  &quot;error&quot;: {
    &quot;code&quot;: &quot;validation_error&quot;,
    &quot;message&quot;: &quot;The &#x27;amount&#x27; field must be a positive integer.&quot;,
    &quot;field&quot;: &quot;amount&quot;,
    &quot;doc_url&quot;: &quot;https://api.example.com/docs/errors#validation_error&quot;
  }
}
</code></pre>
<p>The developer who spent most of a day on the second API can now classify the failure (<code>code</code>), fix the right field (<code>field</code>), and read the exact doc page in thirty seconds (<code>doc_url</code>). That is the integration experience that takes two hours, not a day.</p>
<h2>When the standard schema doesn&#x27;t fit</h2>
<p><strong>Streaming APIs.</strong> A streaming response that errors mid-stream cannot return a JSON body in the standard shape because the HTTP headers have already been sent with a 200 status. The convention is to emit an error event in the stream or a trailing error frame in the final chunk. The error object inside the event or frame should follow the same schema with <code>code</code>, <code>message</code>, and <code>doc_url</code>, even though the transport is different.</p>
<aside><p><strong>GraphQL.</strong> This transport model is different: HTTP status is often 200, and errors live in the response <code>errors</code> array. The same principles still apply and should be mapped through each error&#x27;s <code>extensions</code>.</p></aside>
<p><strong>API versioning.</strong> Changing your error schema is a breaking change. If you ship v1 with an inconsistent schema, clients will write error handling branches against whatever shape you shipped. Changing it requires a new API version. Establish the schema before v1 ships. If you are already past that point, version the error schema independently. Add the new fields without removing the old ones until a major version gives you the clean break.</p>]]></content:encoded>
      <pubDate>Sun, 26 Apr 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Wale Bashir)</author>
      <category>DeveloperExperience</category>
      <category>API</category>
      <category>Nodejs</category>
      <category>Tutorials</category>
      <enclosure url="https://reclear.io/blog/api-error-handling-best-practices/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Technical Content Marketing for B2B SaaS in 2026: A Practical Guide</title>
      <link>https://reclear.io/blog/technical-content-marketing-guide</link>
      <guid isPermaLink="true">https://reclear.io/blog/technical-content-marketing-guide</guid>
      <description>Learn how to build a technical content marketing strategy for B2B SaaS that drives product adoption, developer engagement, and pipeline growth.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/technical-content-marketing-guide/cover/cover.webp" alt="Technical Content Marketing for B2B SaaS in 2026: A Practical Guide" /></p><aside><ul>
<li>Technical content marketing focuses on helping developers and technical buyers learn, solve problems, and use your product</li>
<li>High-impact content starts with real technical problems and translates them into practical workflows and tutorials</li>
<li>Distribution and discoverability across search, communities, and AI tools are critical for content to work</li>
<li>Success should be measured by engagement, product usage, and pipeline, not just traffic</li>
</ul></aside>
<p>Most B2B SaaS teams are producing content consistently, but very few can clearly tie it to product adoption or revenue. Content drives traffic, but not usage. This gap is becoming more obvious as buying behavior shifts. Recent data shows that <a href="https://corporatevisions.com/blog/b2b-buying-behavior-statistics-trends">83% of B2B buyers</a> mostly or fully define their purchase requirements before speaking with sales, meaning content now carries most of the evaluation process.</p>
<p>This is where technical content marketing becomes critical. Unlike general content, technical content is designed to help developers and technical buyers actually use your product, not just learn about it. It connects directly to real workflows, tutorials, and use cases, turning content into a path toward activation and adoption instead of just awareness.</p>
<p>This article shows how to build a technical content marketing system for B2B SaaS in 2026 that drives real product usage, from defining what technical content is to creating, distributing, and measuring content that actually works.</p>
<h2>What is technical content marketing</h2>
<p>Technical content marketing is the practice of creating accurate, educational, and practical content for technical audiences such as developers, engineers, DevOps teams, security teams, and technical decision-makers. Unlike general content marketing, it cannot rely on surface-level advice or broad messaging. Technical readers expect depth, clarity, and proof. They want content that helps them understand a concept, solve a problem, compare options, or build something that works.</p>
<p>This can include developer tutorials, implementation guides, architecture breakdowns, API walkthroughs, integration guides, comparison pages, and technical explainers. Some of these pieces may be closely tied to your product. Others may simply educate the audience around problems your product helps solve.</p>
<aside><p>The goal is not to force the product into every article. The goal is to build trust with a technical audience by being useful, accurate, and specific. When done well, technical content becomes part of how people discover you, evaluate your expertise, and decide whether your product is worth trying.</p></aside>
<p>For B2B SaaS teams, this matters because technical buyers do not convert through vague promises. They need evidence. They need clear explanations. They need practical examples. And often, they need content they can share with other stakeholders before making a decision.</p>
<p>Once this foundation is clear, the next step is building a repeatable technical content marketing system that turns technical expertise into content people can find, trust, and act on.</p>
<h2>Step 1: Start with real technical problems and questions</h2>
<p>Strong technical content marketing does not begin with keywords. It begins with real problems developers and engineers are trying to solve. These are the questions they ask when something is not working, when they are evaluating a new approach, or when they need to build something under time pressure.</p>
<p>You can find these problems in places your team already has access to: support tickets, GitHub issues, engineering discussions, sales calls, and community forums. The goal is to capture the exact language people use and the situations they are in. This ensures your content reflects real-world challenges, not assumed ones.</p>
<p>A simple way to structure this is to map problems to content opportunities:</p>
<table><thead><tr><th>Real Technical Question</th><th>Content Opportunity</th></tr></thead><tbody><tr><td>How do I handle authentication securely?</td><td>Guide to implementing secure authentication workflows</td></tr><tr><td>How do I send events between services?</td><td>Tutorial on building and handling webhooks</td></tr><tr><td>How do I reduce API latency?</td><td>Performance optimization guide with practical examples</td></tr></tbody></table>
<p>When your content starts from real technical questions, it becomes immediately relevant. This is also where technical content naturally connects with <a href="https://reclear.io/blog/developer-documentation-that-gets-used">developer documentation</a>, because both are grounded in helping someone solve a problem or complete a task.</p>
<p>Once you have a clear set of real problems, the next step is building content that educates deeply before introducing any solution.</p>
<h2>Step 2: Build educational depth before product positioning</h2>
<p>Once you identify real technical problems, the next step is to teach them properly before introducing any solution.</p>
<p>Technical audiences expect accuracy and clarity. They want to understand how something works, what options exist, and the tradeoffs involved. If your content jumps straight to your product, it loses credibility quickly.</p>
<p>For example, instead of starting with your tool, break down the problem first:</p>
<ul>
<li>What is happening under the hood</li>
<li>What approaches are commonly used</li>
<li>What challenges or tradeoffs exist</li>
</ul>
<p>Only after this should your product come in, as part of the solution, not the starting point.</p>
<p>A simple pattern to follow:</p>
<p><img src="https://reclear.io/blog/technical-content-marketing-guide/diagrams/problem-to-relevant-solution-diagram.webp" alt="Flow from problem to explanation, approaches, and then solution if relevant."/></p>
<p>This approach builds trust first. And when trust is established, product positioning feels natural instead of forced.</p>
<h2>Step 3: Translate problems into practical workflows</h2>
<p>Once the problem is clearly explained, the next step is to show how it is solved in practice. This is where technical content moves from being informative to being useful.</p>
<p>Technical audiences do not just want to understand a concept. They want to see how it is applied. This means turning ideas into clear, step-by-step workflows that lead to a working outcome. This is where formats like developer tutorials, implementation guides, and walkthroughs become important.</p>
<p>For example, instead of stopping at explaining webhooks, you move into a workflow:</p>
<ul>
<li>create an endpoint</li>
<li>send a test event</li>
<li>receive and validate the payload</li>
</ul>
<p>This is what bridges the gap between learning and doing. It is also where strong developer documentation plays a role, because workflows should be structured in a way that someone can follow and reproduce without guesswork.</p>
<p>If your content does not lead to a clear outcome, it remains theory. But when it shows a complete workflow, it becomes something developers can act on immediately.</p>
<h2>Step 4: Introduce the product naturally within context</h2>
<p>Once you have explained the problem and shown a practical workflow, this is where the product can come in.</p>
<p>The key is positioning. Your product should appear as part of the solution, not the starting point. By this stage, the reader already understands the problem and how it is typically solved. Introducing your product now makes it feel relevant and helpful, not forced.</p>
<p>For example, after walking through a webhook workflow, you can show how your product simplifies one or more steps:</p>
<ul>
<li>handling retries automatically</li>
<li>managing event delivery</li>
<li>providing built-in monitoring</li>
</ul>
<p>This works because the reader already understands the complexity. The product becomes a way to reduce friction, not something they are being pushed to use.</p>
<p>When done well, the product feels like a natural extension of the workflow. It proves its value through context, not promotion.</p>
<h2>Step 5: Distribute content where technical audiences search and learn</h2>
<p>Great content that nobody finds is wasted effort. Technical content needs to be present where developers actually look for answers.</p>
<p>Focus on channels where technical discovery happens:</p>
<ul>
<li>search, where high-intent queries bring solution-driven traffic</li>
<li>developer communities like Stack Overflow, Reddit, and Hacker News</li>
<li>social platforms such as X and LinkedIn where technical discussions happen</li>
</ul>
<p>Increasingly, developers also rely on AI tools like ChatGPT and Claude to find answers. Content that is clear, structured, and practical is more likely to be referenced in these responses.</p>
<p>Distribution is not about promotion. It is about making your content easy to find at the exact moment someone is trying to solve a problem.</p>
<h2>Step 6: Measure Impact, Not Vanity Metrics</h2>
<p>If you only measure pageviews, you are measuring attention, not impact.</p>
<p>Technical content marketing should be tied to what happens after someone reads the content. The real question is not “did they visit?” but “did they take a meaningful step forward?”</p>
<p>Focus on metrics that reflect actual progress:</p>
<ul>
<li><strong>engagement:</strong> time spent, scroll depth, and interaction with the content</li>
<li><strong>tutorial completion:</strong> did they follow through and reach a working result</li>
<li><strong>product usage:</strong> did they sign up and try something in the product</li>
<li><strong>pipeline contribution:</strong> did content influence evaluation or conversion</li>
</ul>
<p>A simple way to think about it:</p>
<p><img src="https://reclear.io/blog/technical-content-marketing-guide/diagrams/content-to-revenue-diagram.webp" alt="Flow from content to action, product usage, and revenue."/></p>
<p>This is what turns content from publishing into a system.</p>
<p>To make this measurable, connect your content to product and CRM data. Track which content users interact with before signing up, activating features, or converting. Over time, this shows which content actually drives growth and which only drives traffic.</p>
<p>When you measure the right things, your content strategy becomes easier to improve. You stop guessing and start investing in what works.</p>
<h2>Common mistakes to avoid in technical content marketing</h2>
<p>Even with the right strategy, many B2B SaaS teams fall into patterns that limit the impact of their technical content. These mistakes often lead to traffic without adoption and content that gets read but not used.</p>
<p>Here are the most common ones to avoid:</p>
<ol>
<li><strong>Writing for traffic instead of usage</strong></li>
</ol>
<p>It is easy to prioritize keywords and search volume, but traffic alone does not drive growth. Content like “what is API authentication”, “benefits of microservices” may attract visitors, but it rarely leads to action. Technical content should be tied to real workflows and outcomes. The goal is not just to rank, but to help someone solve a problem or build something.</p>
<ol start="2">
<li><strong>Too much theory, not enough execution</strong></li>
</ol>
<p>Technical audiences do not just want explanations. They want to see how things work in practice.</p>
<p>Long, theory-heavy content without examples, steps, working outputs quickly loses attention. Meanwhile, strong technical content balances explanation with execution. It shows how to apply the concept, not just understand it.</p>
<ol start="3">
<li><strong>No clear connection to real problems</strong></li>
</ol>
<p>Some content is technically correct but disconnected from real use cases.</p>
<p>If your content does not answer:</p>
<ul>
<li>what problem this solves</li>
<li>when it should be used</li>
</ul>
<p>It becomes difficult for readers to relate to it. Content should be grounded in real developer problems, not abstract ideas.</p>
<ol start="4">
<li><strong>Forcing product into every piece</strong></li>
</ol>
<p>Technical content marketing is not product promotion in disguise. When every article is centered around your product: trust drops, credibility is reduced. Instead, focus on solving the problem first. Introduce the product where it naturally fits within the workflow.</p>
<ol start="5">
<li><strong>Ignoring distribution and discovery</strong></li>
</ol>
<p>Publishing content is only half the work. If your content is not optimized for search, shared in relevant communities, and structured for AI discovery, it will not reach the people it is meant for. Technical content must be discoverable where developers actually search and learn.</p>
<ol start="6">
<li><strong>Measuring the wrong metrics</strong></li>
</ol>
<p>Focusing only on pageviews and impressions gives a false sense of success. Without tracking engagement, usage, and conversion, it is impossible to know what is working. Technical content marketing should be tied to real outcomes, not just visibility.</p>
<p>Avoiding these mistakes ensures your technical content does not just attract attention, but actually drives learning, usage, and growth.</p>
<h2>Conclusion</h2>
<p>Technical content marketing for B2B SaaS is not about publishing more content. It is about creating content that helps technical audiences learn, solve real problems, and move toward actual product usage.</p>
<p>When your content is grounded in real problems, structured around practical workflows, and distributed where developers actively search, it becomes more than marketing. It becomes part of how people evaluate, adopt, and trust your product.</p>
<p>At <a href="https://reclear.io">Reclear</a>, we help B2B SaaS teams turn technical expertise into content that drives real adoption and pipeline, not just traffic. If you are looking to build a technical content system that actually works, you can <a href="https://reclear.io/contact">reach out</a> to see how we can help.</p>]]></content:encoded>
      <pubDate>Sun, 26 Apr 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Olusegun Durojaye)</author>
      <category>ContentStrategy</category>
      <category>B2BSaaS</category>
      <category>Marketing</category>
      <enclosure url="https://reclear.io/blog/technical-content-marketing-guide/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Rate Limiting Algorithms: Code Examples and Comparison</title>
      <link>https://reclear.io/blog/rate-limiting-algorithms-code-examples</link>
      <guid isPermaLink="true">https://reclear.io/blog/rate-limiting-algorithms-code-examples</guid>
      <description>Compare token bucket, sliding window, and fixed window rate limiting, then implement each algorithm in Node.js with Redis.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/rate-limiting-algorithms-code-examples/cover/cover.webp" alt="Rate Limiting Algorithms: Code Examples and Comparison" /></p><aside><ul>
<li>Token bucket, fixed window, and sliding window are not interchangeable. Each algorithm makes a different tradeoff between accuracy, memory cost, and burst behavior.</li>
<li>Fixed window is cheap but leaks at boundaries. Token bucket allows intentional burst, which is also its failure mode. Sliding window log is the most accurate but the most expensive.</li>
<li>Sliding window counter (the Cloudflare hybrid) is what most production systems actually use.</li>
<li>The <a href="https://reclear.io/blog/webhook-handlers-best-practices-retry-patterns">previous article in this series</a> covered exponential backoff as the client-side answer to unreliable delivery. This is the server-side answer.</li>
</ul></aside>
<p>Two APIs can set identical request limits and behave completely differently under burst traffic or distributed retries, depending on the algorithm each uses. This article covers four rate limiting algorithms (fixed window, token bucket, sliding window log, and sliding window counter) with Node.js and Redis implementations for each, and a decision framework for choosing between them.</p>
<h2>A fintech API, a flash sale, and 800 simultaneous 429s</h2>
<p>A payments API ships token bucket rate limiting. It works fine at the expected 100 requests per minute. Then a flash sale hits. 800 clients all retry after a shared payment timeout, which is exactly what exponential backoff does when the base delay is the same across all clients. Every client fires at the same second. The token bucket empties instantly. Every client receives a 429.</p>
<p>The bucket refills 60 seconds later. All 800 clients, still using the same exponential backoff base, retry at the same time. Another 429 storm. The cycle repeats.</p>
<p>Token bucket didn&#x27;t cause this. But it didn&#x27;t stop it either. A sliding window counter would have absorbed the burst differently. This article explains why and gives you the implementation for all four algorithms so you can make the choice deliberately, not by accident.</p>
<h2>What the algorithm choice controls</h2>
<p>The naive framing is that rate limiting means setting a number. 100 requests per minute. Done. The real problem is that two APIs with an identical limit but different algorithms behave completely differently under burst traffic, distributed retries, or coordinated load.</p>
<p>The algorithm choice controls three things:</p>
<ul>
<li><strong>How burst is handled</strong>: does the limit smooth traffic or does it allow front-loading?</li>
<li><strong>Where the accuracy boundary is</strong>: can a client legally double their effective rate by straddling a window reset?</li>
<li><strong>What it costs per user in Redis</strong>: O(1) for counters, O(requests) for log-based approaches.</li>
</ul>
<p>Getting this wrong doesn&#x27;t just mean some users get 429s they shouldn&#x27;t. It means clients who understand your algorithm can exploit boundary behavior while legitimate users under retry load get blocked.</p>
<aside><p><strong>What you&#x27;ll need:</strong> Node.js 18+, Redis 7+ reachable from your API service, and Express 4+ or 5+ in your project.</p></aside>
<p>Use one shared Redis connection module and import it across your middleware.</p>
<pre><code class="language-typescript">import { createClient } from &quot;redis&quot;;

export const redis = createClient({
  url: process.env.REDIS_URL ?? &quot;redis://localhost:6379&quot;,
});

redis.on(&quot;error&quot;, (err) =&gt; console.error(&quot;Redis client error:&quot;, err));

await redis.connect();
</code></pre>
<aside><p>All examples use <code>node-redis</code> v4, the client currently maintained by Redis Labs. If your codebase uses <code>ioredis</code>, the Redis commands are identical; only the client instantiation differs.</p></aside>
<h2>Fixed window: the default most teams ship</h2>
<p>Divide time into discrete windows (every 60 seconds, every minute, every hour). Each window gets a counter. When a request arrives, increment the counter. If it exceeds the limit, reject the request. At the start of the next window, the counter resets.</p>
<p><img src="https://reclear.io/blog/rate-limiting-algorithms-code-examples/diagrams/fixed-window.webp" alt="Fixed window rate limiting: timeline showing discrete 60-second windows, each with independent counters that reset at window boundaries"/></p>
<p>Here is the Express middleware implementation:</p>
<pre><code class="language-typescript">import { redis } from &quot;../lib/redis&quot;;

interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}

export async function fixedWindow(
  userId: string,
  limit: number,
  windowSecs: number
): Promise&lt;RateLimitResult&gt; {
  const windowStart = Math.floor(Date.now() / 1000 / windowSecs) * windowSecs;
  const key = `rate:fixed:${userId}:${windowStart}`;

  const count = await redis.incr(key);

  // Set TTL atomically to prevent keys without expiration
  await redis.expire(key, windowSecs, &quot;NX&quot;);

  const resetAt = windowStart + windowSecs;

  return {
    allowed: count &lt;= limit,
    remaining: Math.max(0, limit - count),
    resetAt,
  };
}
</code></pre>
<p><strong>The boundary exploit.</strong> Consider a limit of 100 requests per minute, and a client who sends 100 requests at 11:59:55 (inside the current window, allowed) then 100 requests at 12:00:05 (inside the next window, also allowed). Both batches pass. The client has sent 200 requests in 10 seconds against a &quot;100 per minute&quot; limit. The window reset at 12:00:00 effectively doubled their burst capacity.</p>
<p>This is not a bug in the implementation. It is a property of the algorithm. If your traffic is uniform and your clients are not adversarial, fixed window is fine: it is cheap, predictable, and easy to explain. If your clients are retry-heavy or your API is public, the boundary burst is a real exploit.</p>
<p><strong>Use fixed window when:</strong> your traffic is internal, predictable, and clients are not coordinating retries.</p>
<h2>Token bucket: controlled burst</h2>
<p>Imagine a bucket that holds tokens. Tokens refill at a constant rate (e.g., 10 tokens per second). Each request consumes one token. If the bucket has tokens, the request is allowed. If it&#x27;s empty, the request is rejected. The bucket has a maximum capacity, and excess tokens are discarded.</p>
<p>The burst behavior is the defining feature: if a client has been quiet for 10 seconds and the refill rate is 10 tokens/sec, they&#x27;ve accumulated up to <code>capacity</code> tokens and can fire them all at once.</p>
<p><img src="https://reclear.io/blog/rate-limiting-algorithms-code-examples/diagrams/token-bucket.webp" alt="Token bucket rate limiting: bucket holds tokens that refill at constant rate; requests consume tokens; burst allowed up to capacity"/></p>
<p>The naive Redis implementation reads the token count and last refill time, calculates the new count, then writes it back. Under concurrent requests, two workers can read the same stale count, both approve a request, and you&#x27;ve allowed twice the intended traffic. This is a real race condition that surfaces under load.</p>
<p>The solution is an atomic read-modify-write using a Lua script. Redis executes Lua scripts as a single atomic operation, so no other command can execute between the read and the write.</p>
<pre><code class="language-typescript">import { redis } from &quot;../lib/redis&quot;;

// Lua script runs atomically; no race condition between read and write
const TOKEN_BUCKET_SCRIPT = `
local key        = KEYS[1]
local capacity   = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])   -- tokens per millisecond
local now        = tonumber(ARGV[3])   -- current time in ms

local data      = redis.call(&#x27;HMGET&#x27;, key, &#x27;tokens&#x27;, &#x27;last_refill&#x27;)
local tokens    = tonumber(data[1]) or capacity
local lastRefill = tonumber(data[2]) or now

-- Continuous refill: tokens += elapsed * rate
-- This prevents the thundering herd caused by discrete tick-based refills
local elapsed     = math.max(0, now - lastRefill)
local tokensToAdd = elapsed * refillRate
tokens = math.min(capacity, tokens + tokensToAdd)

-- Consume one token if available
if tokens &gt;= 1 then
  tokens = tokens - 1
  redis.call(&#x27;HMSET&#x27;, key, &#x27;tokens&#x27;, tokens, &#x27;last_refill&#x27;, now)
  redis.call(&#x27;PEXPIRE&#x27;, key, math.ceil(capacity / refillRate))
  return {1, math.floor(tokens)}
else
  return {0, 0}
end
`;

interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}

export async function tokenBucket(
  userId: string,
  capacity: number,
  refillPerSecond: number
): Promise&lt;RateLimitResult&gt; {
  const refillPerMs = refillPerSecond / 1000;
  const now = Date.now();
  const key = `rate:token:${userId}`;

  const result = (await redis.eval(TOKEN_BUCKET_SCRIPT, {
    keys: [key],
    arguments: [
      String(capacity),
      String(refillPerMs),
      String(now),
    ],
  })) as [number, number];

  const tokensRemaining = result[1];
  const timeToFullRefill = Math.ceil((capacity - tokensRemaining) / refillPerSecond);
  const resetAt = Math.floor(now / 1000) + timeToFullRefill;

  return {
    allowed: result[0] === 1,
    remaining: result[1],
    resetAt,
  };
}
</code></pre>
<aside><p><strong>The continuous refill line is not optional.</strong> Discrete tick-based refills (refilling all tokens at the start of each second) cause a thundering herd. When 10,000 clients are queued against an empty bucket, a tick-based refill releases all of them simultaneously at the second boundary, slamming downstream services. Continuous refill (<code>tokens += elapsed * rate</code> on every request check) spreads arrivals naturally.</p></aside>
<p><strong>The burst tradeoff.</strong> If <code>capacity === limit</code>, a client can legally fire the full limit in milliseconds, wait for a full refill, then fire again. For a 100 request/minute limit with capacity 100, that means 100 requests in 100ms, then another 100 sixty seconds later. If that&#x27;s not the traffic shape you want to allow, reduce burst capacity to 20-30% of the limit, or switch to a sliding window.</p>
<p><strong>Use token bucket when:</strong> you want to explicitly allow controlled bursts, for example, an API that serves batch export operations where clients legitimately need to fire multiple requests in sequence.</p>
<h2>Sliding window log: exact but expensive</h2>
<p>Instead of counters, store a timestamp for every request. When a new request arrives, remove all timestamps older than the window, count what remains, and reject if the count exceeds the limit. The window slides with time, so there is no reset boundary to exploit.</p>
<p><img src="https://reclear.io/blog/rate-limiting-algorithms-code-examples/diagrams/sliding-window.webp" alt="Sliding window log rate limiting: sorted set of request timestamps; window slides continuously; old entries pruned; no boundary burst"/></p>
<p>The middleware uses a Redis sorted set where each member is a timestamped request ID:</p>
<pre><code class="language-typescript">import { redis } from &quot;../lib/redis&quot;;
import { randomUUID } from &quot;crypto&quot;;

interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
  oldestRequestAt: number | null;
}

export async function slidingWindowLog(
  userId: string,
  limit: number,
  windowMs: number
): Promise&lt;RateLimitResult&gt; {
  const now = Date.now();
  const windowStart = now - windowMs;
  const key = `rate:sliding:log:${userId}`;

  // Use unique value to prevent concurrent requests from overwriting each other
  const uniqueValue = `${now}:${randomUUID()}`;

  const pipeline = redis.multi();
  pipeline.zRemRangeByScore(key, 0, windowStart);
  pipeline.zCard(key);
  pipeline.zAdd(key, { score: now, value: uniqueValue });
  pipeline.pExpire(key, windowMs);

  const results = await pipeline.exec();
  const currentCount = (results[1] as number) + 1;

  const resetAt = Math.ceil((now + windowMs) / 1000);

  if (currentCount &gt; limit) {
    await redis.zRem(key, uniqueValue);

    const oldest = await redis.zRange(key, 0, 0, { BY: &quot;RANK&quot; });
    return {
      allowed: false,
      remaining: 0,
      resetAt,
      oldestRequestAt: oldest.length &gt; 0 ? Number(oldest[0].split(&quot;:&quot;)[0]) : null,
    };
  }

  return {
    allowed: true,
    remaining: limit - currentCount,
    resetAt,
    oldestRequestAt: null,
  };
}
</code></pre>
<p><strong>The memory cost.</strong> Every request that hasn&#x27;t aged out of the window is a member of the sorted set. For a user who sends 100 requests per minute with a 60-second window, that&#x27;s 100 members in their set at all times. For a user sending 10,000 requests per minute, that&#x27;s 10,000 members. At 100,000 active users each making 1,000 requests per minute, you&#x27;re looking at 100 million sorted set members. That&#x27;s the real constraint.</p>
<p><strong>Use sliding window log when:</strong> you need exact rate limiting and memory cost is not a constraint, for example, internal APIs or high-value B2B integrations with small user counts.</p>
<h2>Sliding window counter: the production default</h2>
<p>In 2017, Cloudflare published a description of how they implemented rate limiting at scale across millions of domains. The insight was simple: you do not need a full request log to approximate sliding window behavior. Two counters and one calculation get you within 0.003% of the exact count.</p>
<p><img src="https://reclear.io/blog/rate-limiting-algorithms-code-examples/diagrams/sliding-window-counter.webp" alt="Sliding window counter rate limiting: two fixed windows with weighted calculation; previous window fades as current window progresses; O(1) memory cost"/></p>
<p>This weights the previous window&#x27;s count by how much of it still overlaps with the current sliding window. As you move further into the current window, the previous window contributes less. The approximation degrades to at most ~0.003% error in the worst case, which is negligible for practical rate limiting.</p>
<pre><code class="language-typescript">import { redis } from &quot;../lib/redis&quot;;

interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}

export async function slidingWindowCounter(
  userId: string,
  limit: number,
  windowSecs: number
): Promise&lt;RateLimitResult&gt; {
  const now = Date.now() / 1000; // seconds
  const windowStart = Math.floor(now / windowSecs) * windowSecs;
  const prevWindowStart = windowStart - windowSecs;

  const currentKey = `rate:sw:${userId}:${windowStart}`;
  const prevKey    = `rate:sw:${userId}:${prevWindowStart}`;

  // Fetch both counters in one round trip
  const [rawCurr, rawPrev] = await redis.mGet([currentKey, prevKey]);
  const currentCount  = parseInt(rawCurr ?? &quot;0&quot;, 10);
  const previousCount = parseInt(rawPrev ?? &quot;0&quot;, 10);

  const elapsed = now - windowStart;
  const prevWeight = (windowSecs - elapsed) / windowSecs;

  // Weighted estimate of requests in the sliding window
  const estimated = currentCount + previousCount * prevWeight;

  if (estimated &gt;= limit) {
    const resetAt = windowStart + windowSecs;
    return { allowed: false, remaining: 0, resetAt };
  }

  const pipeline = redis.multi();
  pipeline.incr(currentKey);
  pipeline.expire(currentKey, windowSecs * 2); // keep for two windows

  await pipeline.exec();

  const resetAt = windowStart + windowSecs;
  return {
    allowed: true,
    remaining: Math.max(0, Math.floor(limit - estimated - 1)),
    resetAt,
  };
}
</code></pre>
<p>O(1) memory per user, two counters always, four Redis ops per request, and accuracy indistinguishable from a true sliding window for any practical traffic pattern.</p>
<p><strong>Use sliding window counter when:</strong> you need near-exact accuracy at O(1) memory cost. This is the right default for most production SaaS APIs.</p>
<h2>Express middleware</h2>
<p>All four implementations return the same shape. The middleware wraps whichever algorithm you choose.</p>
<p><strong>Step 1: Create the rate-limit middleware</strong></p>
<p>The middleware wraps whichever algorithm function you choose and sets the standard rate limit headers on every response:</p>
<pre><code class="language-typescript">import type { Request, Response, NextFunction } from &quot;express&quot;;
import { slidingWindowCounter } from &quot;./sliding-window-counter&quot;;

interface RateLimitOptions {
  limit: number;
  windowSecs: number;
  keyFn?: (req: Request) =&gt; string;
}

export function rateLimit(options: RateLimitOptions) {
  const { limit, windowSecs, keyFn } = options;

  return async (req: Request, res: Response, next: NextFunction) =&gt; {
    const key = keyFn
      ? keyFn(req)
      : (req.headers[&quot;x-api-key&quot;] as string) ?? req.ip ?? &quot;anonymous&quot;;

    const result = await slidingWindowCounter(key, limit, windowSecs);

    res.setHeader(&quot;X-RateLimit-Limit&quot;, limit);
    res.setHeader(&quot;X-RateLimit-Remaining&quot;, result.remaining);
    res.setHeader(&quot;X-RateLimit-Reset&quot;, result.resetAt);

    if (!result.allowed) {
      const retryAfter = Math.max(1, result.resetAt - Math.floor(Date.now() / 1000));
      res.setHeader(&quot;Retry-After&quot;, retryAfter);

      return res.status(429).json({
        error: {
          code: &quot;rate_limit_exceeded&quot;,
          message: &quot;Too many requests. Please retry after the reset window.&quot;,
          retry_after: retryAfter,
        },
      });
    }

    next();
  };
}
</code></pre>
<p><strong>Step 2: Apply the middleware in your Express app</strong></p>
<p>Register it globally and override it on individual routes that need tighter limits:</p>
<pre><code class="language-typescript">import express from &quot;express&quot;;
import { rateLimit } from &quot;./middleware/rate-limit&quot;;

const app = express();

// Global limit: 100 requests per minute per API key
app.use(rateLimit({ limit: 100, windowSecs: 60 }));

// Tighter limit on expensive endpoints
app.post(
  &quot;/api/export&quot;,
  rateLimit({
    limit: 5,
    windowSecs: 60,
    keyFn: (req) =&gt; `export:${req.headers[&quot;x-api-key&quot;]}`,
  }),
  exportHandler
);
</code></pre>
<h2>Algorithm comparison</h2>
<table><thead><tr><th style="text-align:left">Algorithm</th><th style="text-align:left">Accuracy</th><th style="text-align:left">Memory per user</th><th style="text-align:left">Burst handling</th><th style="text-align:left">Redis ops</th><th style="text-align:left">Best for</th></tr></thead><tbody><tr><td style="text-align:left">Fixed Window</td><td style="text-align:left">Low</td><td style="text-align:left">O(1)</td><td style="text-align:left">Boundary burst allowed</td><td style="text-align:left">2</td><td style="text-align:left">Internal, low-risk APIs</td></tr><tr><td style="text-align:left">Token Bucket</td><td style="text-align:left">Medium</td><td style="text-align:left">O(1)</td><td style="text-align:left">Explicit burst capacity</td><td style="text-align:left">3 (Lua)</td><td style="text-align:left">Batch operations, controlled burst</td></tr><tr><td style="text-align:left">Sliding Window Log</td><td style="text-align:left">High</td><td style="text-align:left">O(requests)</td><td style="text-align:left">Exact, no burst</td><td style="text-align:left">3</td><td style="text-align:left">Internal APIs, small user base</td></tr><tr><td style="text-align:left">Sliding Window Counter</td><td style="text-align:left">Medium-High</td><td style="text-align:left">O(1)</td><td style="text-align:left">Approximate, very close</td><td style="text-align:left">4</td><td style="text-align:left">Most production SaaS APIs</td></tr></tbody></table>
<h2>What about leaky bucket?</h2>
<p>Leaky bucket processes requests at a fixed outbound rate regardless of how they arrive, so requests queue up and drain at a constant pace. This is <strong>traffic shaping</strong>, not rate limiting.</p>
<p><img src="https://reclear.io/blog/rate-limiting-algorithms-code-examples/diagrams/leaky-bucket.webp" alt="Leaky bucket: requests queue and drain at constant rate; traffic shaping for downstream protection; queue-based smoothing"/></p>
<p>Token bucket is a policy on who is allowed in. Leaky bucket is a control on how fast you talk to downstream services. If you are protecting an API from clients, use token bucket or sliding window. If you are protecting a downstream service such as a database or a third-party API from your own application&#x27;s burst writes, leaky bucket is the right primitive.</p>
<h2>The decision framework</h2>
<p>Three questions decide the algorithm:</p>
<p><strong>1. Does the boundary burst matter?</strong> If your users are internal engineers, the boundary exploit is not a real threat. Fixed window is fine. If your API is public or handles payments, use sliding window counter.</p>
<p><strong>2. Do you want to allow burst by design?</strong> If clients legitimately need to fire a batch of requests quickly (exports, bulk operations, data imports), token bucket with a configured burst capacity is the right choice. Set <code>capacity</code> to the maximum burst you want to allow, not the per-minute limit.</p>
<p><strong>3. Do you need exact accuracy or is approximate good enough?</strong> If you are rate limiting against a financial or regulatory threshold where every request matters, use sliding window log and accept the memory cost. For everything else, the sliding window counter&#x27;s ~0.003% approximation error is negligible.</p>
<p>For most SaaS APIs: <strong>sliding window counter is the right default.</strong> It has O(1) memory, near-exact accuracy, and the Cloudflare proof point.</p>
<h2>Production notes</h2>
<p><strong>Rate limit by the right key.</strong> Rate limiting by IP is the weakest possible key. An attacker behind a CDN or a shared office network all looks like one IP. Choose the key that matches your threat model:</p>
<table><thead><tr><th>Key</th><th>When to use</th></tr></thead><tbody><tr><td>Per API key</td><td>Most authenticated APIs</td></tr><tr><td>Per user ID</td><td>When one user can have multiple API keys</td></tr><tr><td>Per endpoint + user</td><td>High-value endpoints that need individual limits</td></tr><tr><td>Per IP</td><td>Unauthenticated endpoints only (login, registration, password reset)</td></tr></tbody></table>
<p><strong>Always return <code>Retry-After</code> on a 429.</strong> Express it as integer seconds from now, not an HTTP date. The IETF rate limit headers draft specifies integer seconds specifically because it does not rely on clock synchronization and is resilient to clock skew. A 429 without <code>Retry-After</code> is a useless error for clients that implement polite retry behavior.</p>
<aside><p>If you&#x27;re running on Cloudflare Workers or Vercel Edge Functions, the Redis implementations in this article don&#x27;t apply directly. Both platforms have native rate limiting primitives that operate at the edge; use those rather than adding a Redis round trip to every request.</p></aside>
<h2>When rate limiting is the wrong tool</h2>
<p><strong>Bad client, no backoff.</strong> If the real problem is a client retrying without any delay, rate limiting will not fix it; the client will hammer you until enough requests get through. The solution is fixing the client. The <a href="https://reclear.io/blog/webhook-handlers-best-practices-retry-patterns">previous article on webhooks and retries</a> covers exponential backoff with jitter from the client side.</p>
<p><strong>One expensive endpoint.</strong> If a single endpoint is your bottleneck (a full-table scan, a slow external API call), rate limiting the whole API hides the problem instead of solving it. The right fix is per-endpoint limits with explicit resource quotas, or refactoring the expensive operation.</p>
<p><strong>Protecting downstream services.</strong> If you&#x27;re trying to protect a database from your own application&#x27;s burst writes, rate limiting client requests is the wrong layer. Circuit breakers, which stop issuing requests to a downstream service when it starts failing, are the right pattern. Rate limiting restricts clients. Circuit breakers protect dependencies.</p>]]></content:encoded>
      <pubDate>Thu, 23 Apr 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Wale Bashir)</author>
      <category>Tutorials</category>
      <category>RateLimiting</category>
      <category>Redis</category>
      <category>Nodejs</category>
      <enclosure url="https://reclear.io/blog/rate-limiting-algorithms-code-examples/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>The Shift from DIY Kubernetes to Managed Platform Engineering</title>
      <link>https://reclear.io/blog/the-shift-from-diy-kubernetes-to-managed-platform-engineering</link>
      <guid isPermaLink="true">https://reclear.io/blog/the-shift-from-diy-kubernetes-to-managed-platform-engineering</guid>
      <description>Learn why DIY Kubernetes creates outages and engineering toil, and how managed platform engineering improves resilience in 2026.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/the-shift-from-diy-kubernetes-to-managed-platform-engineering/cover/cover.webp" alt="The Shift from DIY Kubernetes to Managed Platform Engineering" /></p><aside><p>High-growth SaaS organizations frequently encounter catastrophic production outages when their artisanal, do-it-yourself Kubernetes environments collapse under the sheer weight of unmanaged architectural complexity. The transition from managing low-level cluster internals to adopting <strong>Managed Platform Engineering (MPE)</strong> has become the essential substrate for maintaining digital resilience and accelerating product innovation in 2026.</p></aside>
<h2>1. The Day 2 Operations Crisis: When the Enabler Becomes the Bottleneck</h2>
<p><strong>The high-stakes operational pain point for modern engineering teams is no longer the initial deployment of a cluster but the crushing weight of sustained Day 2 operations.</strong> While the industry has spent the last decade perfecting initial deployment strategies, the reality of maintaining complex homegrown orchestration layers has transformed from an elite capability into organizational inertia. When critical services encounter networking bottlenecks or persistent OOMkill loops, your talent often spends hours troubleshooting CNI plugins or tuning resource limits instead of shipping high-value features.</p>
<p>Infrastructure failures in DIY environments frequently manifest as cascading outages that are difficult to diagnose without a specialized platform layer providing standardized observability across the entire estate. According to the <a href="https://survey.stackoverflow.co/2025/">2025 Stack Overflow Developer Survey</a>, more than one-third of professional developers now rely on AI-enabled tools simply to manage the technical debt inherent in infrastructure.</p>
<h2>2. Thesis: The &quot;DIY Kubernetes&quot; Hallucination of Total Control</h2>
<p><strong>For much of the cloud-native era, building and maintaining a custom, homegrown Kubernetes stack was viewed as the definitive hallmark of a high-maturity organization.</strong> Organizations believed that by owning every component of their orchestration layer, they could achieve a level of optimization and security that managed services simply could not provide.</p>
<p>Kubernetes served as the ultimate tool for this empowerment, offering a standardized API that promised to turn complex infrastructure into a programmable utility. However, this badge of honor often came at the hidden cost of undifferentiated heavy lifting that distracted the engineering team from the core mission of their unique product offerings.</p>
<h2>3. Pain Point Analysis: Why Traditional DIY Solutions are Failing</h2>
<p><strong>The current industry standard of DIY Kubernetes management is failing due to an unsustainable cognitive load placed upon developers.</strong> Developers are no longer just responsible for application logic; they are expected to be experts in Docker, Kubernetes runtime settings, Terraform, and <a href="https://www.systemdesignhandbook.com/guides/llm-system-design/">LLM System Design</a>.</p>
<table><thead><tr><th style="text-align:left">Capability Layer</th><th style="text-align:left">DIY Kubernetes (Legacy Status Quo)</th><th style="text-align:left">Managed Platform Engineering (Reclear Standard)</th></tr></thead><tbody><tr><td style="text-align:left"><strong>1. Developer Interface</strong></td><td style="text-align:left">Manual <code>kubectl</code> and raw YAML manifests</td><td style="text-align:left">Self-service Portal with opinionated Golden Paths</td></tr><tr><td style="text-align:left"><strong>2. Security Integration</strong></td><td style="text-align:left">Per-cluster manual audits and bespoke policies</td><td style="text-align:left">Automated guardrails and centralized Security-as-Code</td></tr><tr><td style="text-align:left"><strong>3. Scaling Efficiency</strong></td><td style="text-align:left">High operational toil per individual cluster</td><td style="text-align:left">Centralized management with standardized API abstractions</td></tr><tr><td style="text-align:left"><strong>4. Cognitive Load</strong></td><td style="text-align:left">Developers function as part-time SysAdmins</td><td style="text-align:left">Developers focus exclusively on shipping Product value</td></tr></tbody></table>
<p>The financial implications of maintaining a DIY infrastructure suggest that the human capital cost is often 3x to 5x higher than the cloud consumption costs. When cognitive load exceeds a developer&#x27;s capacity, build times increase, and innovation stalls.</p>
<h2>4. Synthesis: The Rise of Managed Platform Engineering (MPE)</h2>
<p>Managed Platform Engineering resolves the DevOps paradox by shifting the focus from Infrastructure-as-Code to <strong>Platform-as-a-Product</strong>, delivering standardized abstractions that truly empower internal development teams. This involves designing and building Internal Developer Platforms (IDPs) that provide shared, high-quality tools and &quot;Golden Paths.&quot;</p>
<p>The <strong>&quot;Golden Path&quot;</strong> is the definitive output of a mature Platform Engineering strategy, providing an opinionated and automated way to get production-ready code into the users&#x27; hands. These paved roads offer sensible defaults for 80% of use cases while allowing for well-defined extensions for specialized requirements.</p>
<h2>5. Phased Roadmap: Transitioning to the Managed Paved Road</h2>
<div><section><p>Conduct a comprehensive Shadow Ops audit to measure the current state of infrastructure toil and define the baseline ROI for the new platform engineering initiative.</p></section><section><p>Define the first Golden Path for a standard microservice, integrating
automated security and secret management (via <a href="https://infisical.com/blog/best-platform-engineering-tools-2026">Infisical or HashiCorp
Vault</a>) from
the start.</p></section><section><p>Launch a centralized developer portal, such as
<a href="https://backstage.io/">Backstage</a>, to provide a single interface for service
management across all teams and environments.</p></section><section><p>Implement automated validation loops within the CI/CD pipeline to ensure every resource provisioned via the platform adheres to compliance and security standards.</p></section></div>
<h2>6. Engineering Blueprint: The Reclear Standard for Golden Paths</h2>
<p>At Reclear, we believe that high-fidelity documentation must act as a functional blueprint, validated through rigorous hands-on testing. Below is an engineering snippet from the Reclear Platform Engine framework, utilizing <strong>Crossplane</strong> to abstract AWS EKS resource provisioning into a simple, intent-based &quot;Resource Claim.&quot;</p>
<pre><code class="language-hcl"># Purpose: This Blueprint enforces SOC2/ISO 27001 compliance at the resource layer
resource &quot;crossplane_resource_claim&quot; &quot;saas_order_db&quot; {
  apiVersion = &quot;database.reclear.io/v1alpha1&quot;
  kind       = &quot;SecurePostgreSQL&quot;
  metadata {
    name      = &quot;order-service-db&quot;
    namespace = &quot;production-alpha&quot;
  }
  spec {
    parameters {
      storageGB = 50
      dbName    = &quot;orders&quot;
      # Security Guardrail: Enforce encryption by default
      encryptionEnabled = true
      # FinOps Guardrail: Limit instance size to approved production tiers
      instanceSize = &quot;db.m5.large&quot;
      # Networking Guardrail: Private access only via VPC Endpoints
      publicAccess = false
    }
    # Just-in-Time (JIT) Credential Provisioning
    writeConnectionSecretToRef {
      name      = &quot;order-db-secret&quot;
      namespace = &quot;production-alpha&quot;
    }
  }
}
</code></pre>
<p>System validation is an essential component of the Reclear methodology. This &quot;Build and Validate&quot; loop is what separates a professional Managed Platform from a collection of unverified scripts.</p>
<h2>7. Operationalizing with Reclear: Reviews, Fixes, and Implementation</h2>
<p><strong>Reclear is a specialized Technical Content Marketing Agency for SaaS that solves the &#x27;trust gap&#x27; by deploying an internal engineering team that builds and secures architectures.</strong> We are engineers who market, blending deep technical execution with high-intent growth strategy.</p>
<p>Our team provides the hands-on expertise to implement the Reclear Engineering Standard within your production environment, ensuring your transition from DIY Kubernetes is seamless and secure. We embed with your team to review your current stack, identify critical vulnerabilities, and install the &quot;Golden Paths&quot; described above.</p>
<h2>8. Why it Matters to the Board: The ROI of Abstraction</h2>
<p>Translating technical platform decisions into business resilience and revenue protection is critical for securing budget and cultural buy-in from the Board.</p>
<ul>
<li><strong>Developer Velocity:</strong> Every hour spent on infrastructure toil is an hour NOT spent on customer-facing features. By abstracting cluster internals, you reduce <a href="https://www.softwareseni.com/blog/developer-burnout-cognitive-load/">developer burnout and cognitive load</a>, which helps preserve a team&#x27;s long-term ability to out-innovate its competitors.</li>
<li><strong>Predictable FinOps:</strong> A Managed Platform provides automated rightsizing and scheduling, which can reduce cloud waste by an average of 30%, directly impacting gross margins.</li>
</ul>
<h2>Conclusion: The End of the DIY Era</h2>
<p>The era of the artisanal, DIY Kubernetes cluster is over, replaced by the era of the Managed Platform: a paved road that empowers your developers to scale rapidly. Organizations that fail to abstract their infrastructure complexity will find themselves buried under technical debt, while those that embrace Managed Platform Engineering will have the agility to lead.</p>
<p>At Reclear, we build these roads. If your infrastructure has become a bottleneck rather than a catalyst for growth, it is time to move beyond the DIY status quo and embrace the platform engineering future.</p>
<p><strong>Is your engineering team shipping high-value features, or are they just shipping endless lines of raw, unvalidated YAML?</strong></p>
<p><a href="https://reclear.io/book-a-call">Request an Architectural Review</a>: Book a call with the Reclear team to audit your Kubernetes infrastructure and implementation.</p>]]></content:encoded>
      <pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Abioye Oyatoye)</author>
      <category>DeveloperExperience</category>
      <category>Kubernetes</category>
      <category>platformengineering</category>
      <category>DevOps</category>
      <enclosure url="https://reclear.io/blog/the-shift-from-diy-kubernetes-to-managed-platform-engineering/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Implementing Zero-Trust Architecture in SaaS: Beyond the VPN</title>
      <link>https://reclear.io/blog/implementing-zero-trust-architecture-in-saas</link>
      <guid isPermaLink="true">https://reclear.io/blog/implementing-zero-trust-architecture-in-saas</guid>
      <description>Traditional VPNs are a liability in modern SaaS environments. Learn how to implement a Zero-Trust Architecture rooted in NIST SP 800-207.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/implementing-zero-trust-architecture-in-saas/cover/cover.webp" alt="Implementing Zero-Trust Architecture in SaaS: Beyond the VPN" /></p><aside><p>Traditional <a href="https://blog.openvpn.net/ztna-and-vpn">Virtual Private Networks (VPNs)</a> rely on a &quot;Castle and Moat&quot; security model that is fundamentally incompatible with modern, distributed SaaS environments. To mitigate the risk of lateral movement and credential-based breaches, SaaS architects must transition to a <a href="https://doi.org/10.6028/NIST.SP.800-207">Zero-Trust Architecture (ZTA)</a> rooted in <a href="https://csrc.nist.gov/publications/detail/sp/800-207/final">NIST SP 800-207</a> and <a href="https://www.cisa.gov/zero-trust-maturity-model">CISA’s Maturity Model</a>. This shift replaces static, network-centric perimeters with dynamic, identity-centric Policy Enforcement Points (PEPs) that verify every request, every time, regardless of location.</p></aside>
<h2>The Disintegration of the Perimeter: Your &quot;Castle&quot; is a Phantom</h2>
<p>In a world of multi-cloud sprawl and remote work, the physical and logical boundaries of your production environment have dissolved, rendering traditional perimeter defenses obsolete.</p>
<p>For years, the gold standard for securing SaaS infrastructure was the <a href="https://www.giovannapatruno.com/blog/technical-content-marketing-what-it-is-and-why-it-matters">&quot;Castle and Moat.&quot;</a> You built a robust perimeter around your Virtual Private Cloud (VPC), and the VPN was the drawbridge. If a user had the key (credentials), they were let across the bridge and granted implicit trust to roam within the walls.</p>
<p>In 2026, this model is a liability. Your developers are on home networks, your data is distributed across AWS S3 buckets, Azure Blob storage, and Google Cloud Buckets, and your workloads are orchestrated across global <a href="https://medium.com/@arcfra/kubernetes-2025-review-2026-forecast-mastering-enterprise-adoption-with-confidence-and-control-7d3e2b2b1b1b">Kubernetes (K8s) clusters</a>. The &quot;perimeter&quot; is no longer a physical or network boundary; it is a phantom.</p>
<p>When you rely on a VPN, you are essentially guarding a door that no longer leads to a single room. The result is a fragmented security posture where <a href="https://www.exabeam.com/explainers/insider-threats/">&quot;trusted&quot; insiders</a> have far more access than they require, creating a massive <a href="https://www.microsoft.com/en-us/security/business/zero-trust">&quot;blast radius&quot;</a> for even the simplest credential theft. For Product Managers, this represents a significant risk to customer trust; for Architects, it represents unmanageable technical debt.</p>
<h2>The Antithesis: Why the VPN is the Primary Enabler of Modern Breaches</h2>
<p>VPNs fail modern security requirements because they provide broad network access, lack continuous verification, and create performance bottlenecks that degrade Developer Experience (DX).</p>
<p>To understand why your SaaS must move toward Zero-Trust, you must first identify where traditional remote access fails. Based on research into modern <a href="https://owasp.org/www-project-top-ten/">breach post-mortems</a>, we identify three specific failure points:</p>
<h3>I. Implicit Trust and the Lateral Movement Problem</h3>
<p>Traditional VPNs are binary: you are either &quot;on&quot; the network or &quot;off&quot; it. Once authenticated, a user often receives a private IP address within a broad subnet. If a frontend engineer logs in to debug a UI component, their session may inadvertently have network reachability to the production database. If that machine is compromised, the threat actor isn&#x27;t just &quot;on the VPN,&quot; they are <a href="https://www.goodaccess.com/blog/ztna-vs-vpn-the-ultimate-showdown">&quot;inside the moat&quot;</a> with a clear path to your most sensitive data.</p>
<h3>II. Identity Blindness and Static Authorization</h3>
<p>A VPN typically authenticates a user at the start of a session. Once the tunnel is established, that trust is rarely re-evaluated. If the user’s <a href="https://www.f5.com/pdf/white-papers/identity-aware-proxy-the-gateway-to-a-zero-trust-architecture-wp.pdf">device posture</a> changes, for example, if they disable their encryption, the VPN remains blind to the risk. This &quot;once-and-done&quot; authentication is the antithesis of modern security.</p>
<h3>III. The &quot;Hairpin&quot; Performance Tax</h3>
<p>Routing all SaaS traffic through a centralized VPN gateway creates significant latency. For global teams, this <a href="https://www.cloudflare.com/learning/access-management/what-is-ztna/">&quot;hairpining&quot; effect</a> forces data to travel to a central hub before reaching the cloud resource. This leads to <a href="https://www.cloudflare.com/learning/access-management/what-is-ztna/">&quot;shadow IT,&quot;</a> where teams bypass security controls entirely to maintain velocity.</p>
<h2>The Synthesis: Resource-Centric Security and NIST SP 800-207</h2>
<p>Zero-Trust Architecture (ZTA) shifts the security boundary from the edge of the network to the edge of the individual resource, utilizing Policy Decision Points (PDPs) to evaluate context-aware risk in real-time.</p>
<p>Transitioning to Zero Trust means adopting a resource-centric model. According to <a href="https://doi.org/10.6028/NIST.SP.800-207">NIST SP 800-207</a>, the core of ZTA is the assumption that no implicit trust is granted to assets based solely on their physical or network location. Security is enforced through <a href="https://www.paloaltonetworks.com/cyberpedia/what-is-nist-sp-800-207">logical components</a>:</p>
<ol>
<li><strong>The Policy Engine (PE):</strong> The brain that decides whether to grant access based on a <a href="https://doi.org/10.6028/NIST.SP.800-207">Trust Algorithm</a>.</li>
<li><strong>The Policy Administrator (PA):</strong> Responsible for establishing or terminating the communication path.</li>
<li><strong>The Policy Enforcement Point (PEP):</strong> The gatekeeper (like an <a href="https://research.google/pubs/beyondcorp-a-new-approach-to-enterprise-security/">Identity-Aware Proxy</a>) that intercepts requests.</li>
</ol>
<h3>The Kipling Method for Policy Design</h3>
<p>For a &quot;201-level&quot; implementation, we recommend the <strong>Kipling Method</strong> (The &quot;Who, What, When, Where, Why, and How&quot;) of access:</p>
<ul>
<li><strong>Who</strong> is requesting access? (<a href="https://www.okta.com/intro-to-zero-trust/">Verified via OIDC</a>).</li>
<li><strong>What</strong> resource is being accessed? (Specific <a href="https://www.microsoft.com/en-us/security/business/zero-trust">K8s pod or DB instance</a>).</li>
<li><strong>When</strong> are they accessing it? (Enables <a href="https://learn.microsoft.com/en-us/security/zero-trust/deploy/overview">Just-in-Time access</a>).</li>
<li><strong>Where</strong> is the request originating? (<a href="https://www.f5.com/pdf/white-papers/identity-aware-proxy-the-gateway-to-a-zero-trust-architecture-wp.pdf">Network health signals</a>).</li>
<li><strong>Why</strong> do they need access? (Linked to a <a href="https://www.reddit.com/r/AskNetsec/comments/18m6v7w/what_are_effective_strategies_for_implementing_a/">ticket or incident</a>).</li>
<li><strong>How</strong> is the device secured? (<a href="https://research.google/pubs/beyondcorp-a-new-approach-to-enterprise-security/">Posture check via MDM</a>).</li>
</ul>
<h2>Phased Implementation Strategy for the Big Three Clouds</h2>
<p>A successful Zero-Trust roadmap requires equal attention to identity, device posture, and application-level proxying across AWS, Azure, and GCP.</p>
<div><section><ul>
<li><strong>AWS:</strong> Leverage <a href="https://aws.amazon.com/blogs/security/new-compliance-guide-available-iso-iec-270012022-on-aws-compliance-guide/">IAM Roles for Service Accounts (IRSA)</a> and IAM Identity Center.</li>
<li><strong>Azure:</strong> Utilize <a href="https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id">Microsoft Entra ID</a> and Managed Identities to eliminate hardcoded credentials.</li>
<li><strong>GCP:</strong> Deploy <a href="https://cloud.google.com/iam/docs/workload-identity-federation">Workload Identity Federation</a> for secure cross-cloud access.</li>
</ul></section><section><ul>
<li><strong>AWS:</strong> Integrate <a href="https://aws.amazon.com/verified-access/">AWS Verified
Access</a> with your MDM to evaluate
security signals. - <strong>Azure:</strong> Use <a href="https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/overview">Conditional Access
Policies</a>
to mandate device compliance. - <strong>GCP:</strong> Implement <a href="https://cloud.google.com/beyondcorp-enterprise">BeyondCorp
Enterprise</a> to provide deep
device-level context.</li>
</ul></section><section><ul>
<li><strong>AWS:</strong> Use <a href="https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html">Application Load Balancers (ALB)</a> with OIDC.</li>
<li><strong>Azure:</strong> Deploy <a href="https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-private-access">Microsoft Entra Private Access</a> for secure remote reach.</li>
<li><strong>GCP:</strong> Utilize <a href="https://cloud.google.com/iap/docs/concepts-overview">Google Cloud IAP</a> to control access to apps and VMs.</li>
</ul></section></div>
<h2>High-Fidelity Documentation: The &quot;Proof of Concept&quot;</h2>
<p><em>At Reclear, we ensure our technical guides are grounded in reality. Documentation should act as a functional blueprint, validated through rigorous testing.</em></p>
<p>Below is a high-fidelity <a href="https://www.terraform.io/">HCL (Terraform)</a> sample showing how a modern SaaS team can document and enforce <strong>Just-in-Time (JIT)</strong> access to production logs.</p>
<pre><code class="language-hcl">resource &quot;aws_iam_policy&quot; &quot;jit_developer_logs&quot; {
  name        = &quot;JIT-Log-Access-Framework&quot;

  policy = jsonencode({
    Version = &quot;2012-10-17&quot;
    Statement = [
      {
        Action   = [&quot;logs:GetLogEvents&quot;, &quot;logs:DescribeLogStreams&quot;]
        Effect   = &quot;Allow&quot;
        Resource = &quot;arn:aws:logs:us-east-1:*:log-group:/production/apps/order-service/*&quot;
        Condition = {
          StringEquals = {
            &quot;aws:PrincipalTag/AccessLevel&quot;: &quot;JIT-Verified&quot;,
            &quot;aws:PrincipalTag/ActiveIncident&quot;: &quot;TRUE&quot;
          }
        }
      }
    ]
  })
}
</code></pre>
<p><strong>The Reclear Standard:</strong> When documenting these architectures, we include the specific <a href="https://aws.amazon.com/blogs/security/new-compliance-guide-available-iso-iec-270012022-on-aws-compliance-guide/">CLI commands</a> required for developers to verify the policy is active.</p>
<h2>Why it Matters to the Board: Resilience as a Revenue Driver</h2>
<p>Zero Trust is a business strategy that reduces the risk of catastrophic data loss, ensures compliance readiness, and accelerates product velocity.</p>
<h3>I. Accelerated Compliance Velocity</h3>
<p>Frameworks like <a href="https://www.fortinet.com/resources/cyberglossary/iso-iec-27001">SOC2</a> and <a href="https://www.fortinet.com/resources/cyberglossary/iso-iec-27001">ISO 27001</a> are becoming the baseline for enterprise procurement. A Zero-Trust architecture simplifies &quot;Evidence Collection&quot; because every request is <a href="https://www.okta.com/identity-101/zero-trust-maturity-model-for-ztaa/">authenticated and contextually validated</a>.</p>
<h3>II. Reduction in Breach Impact (Blast Radius)</h3>
<p>For the Board, the goal is risk mitigation. ZTA contains threats at the <a href="https://www.microsoft.com/en-us/security/business/zero-trust">resource level</a>, protecting brand equity and ensuring <a href="https://cloudsecurityalliance.org/resources/zero-trust-guidance-for-achieving-operational-resilience">operational resilience</a>.</p>
<h3>III. Improved Developer Retention and Experience</h3>
<p>By removing the friction of manual VPN logins and providing <a href="https://www.mintlify.com/blog/quick-formula-for-docs-that-convert">seamless, JIT access</a>, you improve the <a href="https://www.prove.com/blog/developer-experience-dx-unlock-productivity-and-innovation">Developer Experience (DX)</a>, a key differentiator for engineering productivity.</p>
<h2>Conclusion</h2>
<p>Building a <a href="https://growtika.com/blog/digital-ocean-seo-analysis">growth machine</a> for a SaaS company requires an infrastructure (and a content strategy) that <a href="https://www.microsoft.com/en-us/security/business/zero-trust">assumes the perimeter</a> is already compromised.</p>
<p>At Reclear, we specialize in turning these complex architectural shifts into <a href="https://reclear.io/blog/developer-documentation-that-gets-used">clear, high-conversion technical documentation</a>. By prioritizing resource-centric security, you build a platform that your developers and your customers can trust.</p>
<p><a href="https://reclear.io/book-a-call">Work with us</a>: Book a call to discuss how Reclear can help document and communicate your architecture.</p>]]></content:encoded>
      <pubDate>Sun, 19 Apr 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Abioye Oyatoye)</author>
      <category>DeveloperExperience</category>
      <category>zerotrust</category>
      <category>saas</category>
      <category>Security</category>
      <enclosure url="https://reclear.io/blog/implementing-zero-trust-architecture-in-saas/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Webhook Handlers: Why Yours Fails Silently and How to Fix It</title>
      <link>https://reclear.io/blog/webhook-handlers-best-practices-retry-patterns</link>
      <guid isPermaLink="true">https://reclear.io/blog/webhook-handlers-best-practices-retry-patterns</guid>
      <description>Learn why webhook handlers fail silently in production and how to implement retry logic, dead-letter queues, and structured logging.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/webhook-handlers-best-practices-retry-patterns/cover/cover.webp" alt="Webhook Handlers: Why Yours Fails Silently and How to Fix It" /></p><aside><ul>
<li>Most webhook failures that hurt teams are not obvious transport errors. They are false positives where receipt is logged, or <code>2xx</code> is returned, but the actual business work never completes.</li>
<li>A reliable webhook handler separates <strong>receipt</strong> from <strong>processing</strong>. Verify the signature, log the delivery, enqueue the work, and return fast.</li>
<li>Retries need exponential backoff, idempotency, and a dead letter path. Without those, one transient failure becomes silent data loss or duplicate side effects.</li>
<li>The examples use Node.js, Express, Redis, and BullMQ. The model applies to any stack.</li>
</ul></aside>
<p>Most webhook bugs are not loud. There is no <code>500</code>, no DNS failure, no alert firing. The provider marks the delivery successful, your route returns <code>200</code>, and the business action never happens. Nobody knows until a customer asks why their order has not shipped.</p>
<p>The root cause is always the same: <strong>receipt</strong> and <strong>processing</strong> are conflated.</p>
<p>This article builds the fix: a Node.js and Express ingress route, a BullMQ worker with exponential backoff and dead lettering, and Redis idempotency with an atomic claim. The model applies to any stack.</p>
<h2>Why webhook handlers fail silently</h2>
<p>Loud failures like DNS errors, TLS mismatches, and <code>500</code>s are at least visible. Silent failures are not. They look healthy on the wire and broken in outcomes.</p>
<table><thead><tr><th>Failure mode</th><th>What looks successful</th><th>What actually failed</th></tr></thead><tbody><tr><td>You return success before work finishes</td><td>Provider marks delivery OK.</td><td>Async work dies later; no durable outcome.</td></tr><tr><td>You do heavy work inline</td><td>&quot;It worked on my machine.&quot;</td><td>Timeouts and retries under load.</td></tr><tr><td>No retry or dead letter story</td><td>Request hit your logs once.</td><td>Transient blips become permanent loss.</td></tr><tr><td>Receipt and business validation conflate</td><td>HTTP path succeeded.</td><td>Downstream rejects the event; nobody ties it back.</td></tr></tbody></table>
<p>Delivery confirmation is not processing confirmation.</p>
<h2>What the naive handler gets wrong</h2>
<p>The first handler most teams ship works in staging and breaks in production. Here is what that typically looks like for an order events endpoint:</p>
<pre><code class="language-js">import express from &#x27;express&#x27;;

const app = express();

app.post(&#x27;/webhooks/orders&#x27;, express.json(), async (req, res) =&gt; {
  if (req.get(&#x27;x-webhook-signature&#x27;) !== process.env.WEBHOOK_SECRET) {
    return res.status(401).json({ error: &#x27;invalid signature&#x27; });
  }

  await createInvoice(req.body);
  await sendProvisioningEvent(req.body);

  return res.status(200).json({ ok: true });
});

app.listen(3000, () =&gt; {
  console.log(&#x27;listening on 3000&#x27;);
});
</code></pre>
<p>Three problems: it verifies against <strong>parsed</strong> JSON instead of raw bytes; it does real work <strong>inline</strong>, creating timeout and retry risk; and it treats <code>200 OK</code> as &quot;processing finished.&quot;</p>
<p>If <code>createInvoice</code> or <code>sendProvisioningEvent</code> errors after the response is sent, the provider saw a clean delivery and will not retry. That event is gone.</p>
<p><img src="https://reclear.io/blog/webhook-handlers-best-practices-retry-patterns/diagram-false-positive-lifecycle.webp" alt="Flowchart: webhook flows from provider through verification, receipt log, 202 Accepted, and queued job to the worker; dotted paths show a false positive when the provider still sees success, and customer impact that appears later."/></p>
<h2>How to fix it</h2>
<p>Each failure mode above has a direct fix. Applied together, they separate receipt from processing, make retries safe, and give failed deliveries somewhere to land.</p>
<p><img src="https://reclear.io/blog/webhook-handlers-best-practices-retry-patterns/diagram-state-separation.webp" alt="State diagram: Received branches to Rejected or Queued; Processing leads to Processed, RetryScheduled (looping back), or DeadLettered; terminal states include Processed, Rejected, and DeadLettered."/></p>
<h3>1. Verify the signature on raw bytes</h3>
<p>Providers sign <strong>raw</strong> bytes. If you parse the body first, you change the byte representation before you check the signature, which breaks valid events. The route needs <code>express.raw()</code>, not <code>express.json()</code>, for the webhook path.</p>
<pre><code class="language-js">import crypto from &#x27;node:crypto&#x27;;

export function computeSignature(rawBody, secret) {
  return crypto.createHmac(&#x27;sha256&#x27;, secret).update(rawBody).digest(&#x27;hex&#x27;);
}

export function verifySignature(rawBody, providedSignature, secret) {
  const expected = computeSignature(rawBody, secret);
  const expectedBuffer = Buffer.from(expected, &#x27;hex&#x27;);
  const actualBuffer = Buffer.from(providedSignature ?? &#x27;&#x27;, &#x27;hex&#x27;);

  if (expectedBuffer.length !== actualBuffer.length) return false;
  return crypto.timingSafeEqual(expectedBuffer, actualBuffer);
}
</code></pre>
<aside><p>Do not parse the body before verifying. Changing the byte representation breaks valid signatures.</p></aside>
<h3>2. Log receipt before you touch business logic</h3>
<p>Receipt needs its own log line before any business logic runs. When debugging a false positive, you need to know exactly when the signed delivery arrived, separate from what happened downstream. A minimal pino logger handles this:</p>
<pre><code class="language-js">import pino from &#x27;pino&#x27;;

export const logger = pino({
  level: process.env.LOG_LEVEL ?? &#x27;info&#x27;,
  base: undefined,
  timestamp: pino.stdTimeFunctions.isoTime,
});
</code></pre>
<p>Each log entry carries a context object that ties the delivery ID, event type, and attempt number together across receipt and every retry:</p>
<pre><code class="language-js">export function webhookContext({ deliveryId, eventType, attempt }) {
  return {
    component: &#x27;webhook-handler&#x27;,
    deliveryId,
    eventType,
    attempt,
  };
}
</code></pre>
<p>The three fields (<code>deliveryId</code>, <code>eventType</code>, <code>attempt</code>) stitch logs into a traceable execution trail across receipt, processing, and every retry.</p>
<h3>3. Enqueue the work and return 202 immediately</h3>
<p>Most providers time out after 10 seconds. That is not enough to safely run business logic under load. Move work off the request thread: verify, log, enqueue, return <code>202 Accepted</code>. Redis and BullMQ hold the queue.</p>
<p><strong>Step 1: Register the queue and Redis connection</strong></p>
<p>Create the shared Redis connection first, then pass it into the queue.</p>
<pre><code class="language-js">import IORedis from &#x27;ioredis&#x27;;

export const connection = new IORedis(process.env.REDIS_URL ?? &#x27;redis://127.0.0.1:6379&#x27;, {
  maxRetriesPerRequest: null,
});
</code></pre>
<p>With the connection in place, create the queue and configure the retry policy:</p>
<pre><code class="language-js">import { Queue } from &#x27;bullmq&#x27;;
import { connection } from &#x27;../lib/redis.js&#x27;;

export const webhookQueue = new Queue(&#x27;webhook-deliveries&#x27;, {
  connection,
  defaultJobOptions: {
    attempts: 6,
    backoff: {
      type: &#x27;exponential&#x27;,
      delay: 1000,
    },
    removeOnComplete: 1000,
    removeOnFail: false,
  },
});
</code></pre>
<p>Six attempts with 1s exponential backoff yield about <strong>63s</strong> of retry delay before hard failure (1+2+4+8+16+32s).</p>
<p><strong>Step 2: Add the Express route</strong></p>
<pre><code class="language-js">import &#x27;dotenv/config&#x27;;
import express from &#x27;express&#x27;;
import crypto from &#x27;node:crypto&#x27;;
import { logger } from &#x27;./lib/logger.js&#x27;;
import { verifySignature } from &#x27;./lib/signature.js&#x27;;
import { webhookContext } from &#x27;./lib/context.js&#x27;;
import { webhookQueue } from &#x27;./jobs/webhook-queue.js&#x27;;

const app = express();

app.get(&#x27;/health&#x27;, (_req, res) =&gt; {
  res.json({ ok: true });
});

app.post(&#x27;/webhooks/orders&#x27;, express.raw({ type: &#x27;application/json&#x27; }), async (req, res) =&gt; {
  const rawBody = req.body;
  const deliveryId = req.get(&#x27;x-webhook-id&#x27;) ?? crypto.randomUUID();
  const eventType = req.get(&#x27;x-webhook-event&#x27;) ?? &#x27;order.completed&#x27;;
  const signature = req.get(&#x27;x-webhook-signature&#x27;);

  if (!verifySignature(rawBody, signature, process.env.WEBHOOK_SECRET)) {
    logger.warn(
      webhookContext({ deliveryId, eventType, attempt: 0 }),
      &#x27;webhook signature verification failed&#x27;
    );

    return res.status(401).json({ error: &#x27;invalid_signature&#x27; });
  }

  const payload = JSON.parse(rawBody.toString(&#x27;utf8&#x27;));

  logger.info(
    webhookContext({ deliveryId, eventType, attempt: 0 }),
    &#x27;webhook received&#x27;
  );

  await webhookQueue.add(
    &#x27;process-webhook&#x27;,
    { deliveryId, eventType, payload },
    { jobId: deliveryId }
  );

  return res.status(202).json({ accepted: true, deliveryId });
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () =&gt; {
  logger.info({ port }, &#x27;server listening&#x27;);
});
</code></pre>
<p><code>202</code> means accepted for later work, not side effects finished.</p>
<h3>4. Make retries idempotent with a delivery ID</h3>
<p>Most providers deliver at-least-once. Retries are only safe when they are idempotent. Key idempotency on the <strong>delivery ID</strong> and use BullMQ&#x27;s <code>jobId</code> to deduplicate at the queue level before the worker runs. For a deeper look at idempotency, see <a href="https://reclear.io/blog/what-is-idempotency-in-rest-api">What Is Idempotent in REST API Design?</a>. The module below manages three states per delivery: unclaimed, processing, and processed:</p>
<pre><code class="language-js">import { connection } from &#x27;./redis.js&#x27;;

const PREFIX = &#x27;processed:webhook:&#x27;;
const PROCESSED_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days; aligns with typical provider retry/replay windows
const CLAIM_TTL_SECONDS = 5 * 60; // 5 minutes; should exceed p99 handler time

export async function alreadyProcessed(deliveryId) {
  const value = await connection.get(`${PREFIX}${deliveryId}`);
  return value === &#x27;1&#x27;;
}

export async function claimDelivery(deliveryId) {
  const key = `${PREFIX}${deliveryId}`;
  const result = await connection.set(key, &#x27;processing&#x27;, &#x27;EX&#x27;, CLAIM_TTL_SECONDS, &#x27;NX&#x27;);
  return result === &#x27;OK&#x27;;
}

export async function releaseClaim(deliveryId) {
  const key = `${PREFIX}${deliveryId}`;
  const value = await connection.get(key);
  if (value === &#x27;processing&#x27;) {
    await connection.del(key);
  }
}

export async function markProcessed(deliveryId) {
  await connection.set(`${PREFIX}${deliveryId}`, &#x27;1&#x27;, &#x27;EX&#x27;, PROCESSED_TTL_SECONDS);
}
</code></pre>
<aside><p><strong>TTL</strong> on the <code>1</code> marker caps Redis growth. <strong><code>SET … NX</code></strong> is an atomic claim, so only one worker wins per delivery ID even under concurrent retries.</p></aside>
<h3>5. Dead-letter permanent failures</h3>
<p>The worker checks idempotency first, runs the business logic, and distinguishes between errors worth retrying and errors that are permanent. A network timeout is worth retrying. A payload that fails business validation will fail every time, so it goes straight to the dead letter queue.</p>
<pre><code class="language-js">import { Worker, UnrecoverableError } from &#x27;bullmq&#x27;;
import { connection } from &#x27;../lib/redis.js&#x27;;
import { logger } from &#x27;../lib/logger.js&#x27;;
import { webhookContext } from &#x27;../lib/context.js&#x27;;
import { alreadyProcessed, claimDelivery, releaseClaim, markProcessed } from &#x27;../lib/idempotency.js&#x27;;

async function processWebhook(job) {
  const { deliveryId, eventType, payload } = job.data;
  const ctx = webhookContext({ deliveryId, eventType, attempt: job.attemptsMade });

  if (await alreadyProcessed(deliveryId)) {
    logger.info(ctx, &#x27;webhook already processed, skipping&#x27;);
    return { skipped: true };
  }

  const claimed = await claimDelivery(deliveryId);
  if (!claimed) {
    logger.info(ctx, &#x27;webhook claim not acquired, another worker is processing&#x27;);
    return { skipped: true };
  }

  try {
    logger.info(ctx, &#x27;webhook processing started&#x27;);

    if (!payload.orderId) {
      throw new UnrecoverableError(&#x27;missing orderId, dead lettering&#x27;);
    }

    await simulateBusinessLogic(payload);

    await markProcessed(deliveryId);
    logger.info(ctx, &#x27;webhook processed successfully&#x27;);

    return { processed: true };
  } catch (err) {
    await releaseClaim(deliveryId);

    if (err instanceof UnrecoverableError) {
      logger.error({ ...ctx, err: err.message }, &#x27;webhook dead lettered due to a permanent error&#x27;);
      throw err;
    }

    logger.warn({ ...ctx, err: err.message, attempt: job.attemptsMade }, &#x27;webhook processing failed, will retry&#x27;);
    throw err;
  }
}

async function simulateBusinessLogic(payload) {
  // Replace with real logic: createInvoice(payload), sendProvisioningEvent(payload), etc.
  if (payload.simulateTransientError) {
    throw new Error(&#x27;transient error, safe to retry&#x27;);
  }
  await new Promise((resolve) =&gt; setTimeout(resolve, 50));
}

export const worker = new Worker(&#x27;webhook-deliveries&#x27;, processWebhook, {
  connection,
  concurrency: 5,
});

worker.on(&#x27;failed&#x27;, (job, err) =&gt; {
  const isTerminal = !job || job.attemptsMade &gt;= (job.opts.attempts ?? 1);
  if (isTerminal) {
    logger.error(
      webhookContext({ deliveryId: job?.data?.deliveryId, eventType: job?.data?.eventType, attempt: job?.attemptsMade }),
      `webhook moved to dead letter queue: ${err.message}`
    );
  }
});
</code></pre>
<h2>Testing the success, retry, and dead-letter paths</h2>
<p>The only way to trust a retry and dead-letter path is to trigger it deliberately. Start the server and worker together in one terminal:</p>
<pre><code class="language-bash">node server.js &amp;
node jobs/webhook-worker.js
</code></pre>
<p>Then run the test sender from a second terminal:</p>
<pre><code class="language-js">import crypto from &#x27;node:crypto&#x27;;

const BASE_URL = process.env.BASE_URL ?? &#x27;http://localhost:3000&#x27;;
const SECRET = process.env.WEBHOOK_SECRET ?? &#x27;super-secret-demo-key&#x27;;

function sign(body) {
  return crypto.createHmac(&#x27;sha256&#x27;, SECRET).update(body).digest(&#x27;hex&#x27;);
}

async function send(label, payload) {
  const body = JSON.stringify(payload);
  const deliveryId = crypto.randomUUID();

  const res = await fetch(`${BASE_URL}/webhooks/orders`, {
    method: &#x27;POST&#x27;,
    headers: {
      &#x27;content-type&#x27;: &#x27;application/json&#x27;,
      &#x27;x-webhook-signature&#x27;: sign(body),
      &#x27;x-webhook-id&#x27;: deliveryId,
      &#x27;x-webhook-event&#x27;: &#x27;order.completed&#x27;,
    },
    body,
  });

  const json = await res.json();
  console.log(`[${label}] status=${res.status}`, json);
}

await send(&#x27;success&#x27;, { orderId: &#x27;ord_001&#x27;, amount: 9900 });
await send(&#x27;transient&#x27;, { orderId: &#x27;ord_002&#x27;, simulateTransientError: true });
await send(&#x27;dead-letter&#x27;, { simulateTransientError: false }); // missing orderId
</code></pre>
<p>From Terminal 2, run the sender:</p>
<pre><code class="language-bash">node scripts/send-test-webhooks.js
</code></pre>
<p>Watch Terminal 1. The success path logs <code>webhook processed successfully</code>. The transient path logs <code>will retry</code>, then <code>webhook processed successfully</code> after backoff. The dead-letter path logs <code>webhook dead lettered due to a permanent error</code> and stops retrying.</p>
<table><thead><tr><th>Path</th><th>Terminal 1 log</th><th>Terminal 2 status</th></tr></thead><tbody><tr><td>Success</td><td><code>webhook processed successfully</code></td><td><code>202</code></td></tr><tr><td>Transient failure</td><td><code>will retry</code> then <code>webhook processed successfully</code></td><td><code>202</code></td></tr><tr><td>Permanent failure</td><td><code>webhook dead lettered due to a permanent error</code></td><td><code>202</code></td></tr></tbody></table>
<h2>When this pattern doesn&#x27;t fit</h2>
<p>A queue-and-worker setup adds real infrastructure: Redis, BullMQ, a persistent worker, and an idempotency store. Use it when the complexity is warranted.</p>
<table><thead><tr><th>Use it when</th><th>Skip it when</th></tr></thead><tbody><tr><td>Business side effects cannot be lost</td><td>Handler is fast and stateless (cache invalidation, lightweight reads)</td></tr><tr><td>Providers retry on failure and you need idempotency guarantees</td><td>Cold start costs make a persistent worker impractical</td></tr><tr><td>Any single handler step takes more than a few hundred milliseconds</td><td>Dropped notifications are acceptable by design</td></tr><tr><td>You need a dead letter queue to inspect and replay failed deliveries</td><td>Managed queues like SQS and Lambda already handle the separation</td></tr></tbody></table>
<p>The goal is always the same: separate receipt from processing, make retries safe, and give failed deliveries somewhere to land. The specific tools are secondary.</p>]]></content:encoded>
      <pubDate>Mon, 13 Apr 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Wale Bashir)</author>
      <category>Tutorials</category>
      <category>DeveloperExperience</category>
      <category>Documentation</category>
      <enclosure url="https://reclear.io/blog/webhook-handlers-best-practices-retry-patterns/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>The 2026 Architect&apos;s Guide to Multi-Cloud Connectivity: AWS, Azure, and GCP</title>
      <link>https://reclear.io/blog/2026-architects-guide-multi-cloud-connectivity</link>
      <guid isPermaLink="true">https://reclear.io/blog/2026-architects-guide-multi-cloud-connectivity</guid>
      <description>Compare how AWS, Azure, and GCP connect their networks, what inter-cloud traffic costs, and how to secure multi-cloud architecture.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/2026-architects-guide-multi-cloud-connectivity/cover/cover.webp" alt="The 2026 Architect&apos;s Guide to Multi-Cloud Connectivity: AWS, Azure, and GCP" /></p><aside><p>Multi-cloud is now a baseline requirement for enterprise resilience, AI scaling, and data sovereignty. AWS, Azure, and GCP share a hub-and-spoke model but differ significantly in control, pricing, and developer experience. Direct inter-cloud peering now provisions in minutes via API with MACsec encryption enforced at the physical layer. Zero-trust principles are non-negotiable when merging cloud attack surfaces across providers.</p></aside>
<p>Multi-cloud architecture is no longer a tactical experiment or the accidental result of shadow IT.</p>
<p>In 2026, with approximately <a href="https://info.flexera.com/CM-REPORT-State-of-the-Cloud">78% of businesses operating hybrid or multi-cloud strategies</a>, the hyperscalers have stopped competing to host all of your infrastructure. They are competing to be the best home for specific workloads. AWS for orchestration. <a href="https://cloud.google.com/tpu/docs/v5e">GCP&#x27;s TPU v5e clusters</a> for model training. Azure for enterprise OpenAI access.</p>
<p>The challenge for Product Managers, DevOps Engineers, and Architects is connecting these environments without creating latency bottlenecks, runaway egress costs, or security gaps between clouds.</p>
<p>This guide breaks down the networking philosophies of the Big Three, examines what direct inter-cloud peering actually looks like in 2026, and lays out the security model required to keep cross-cloud traffic safe.</p>
<h2>The business case for multi-cloud in 2026</h2>
<p>Before routing a single packet, it helps to align the architecture with the product strategy. Multi-cloud adds real complexity. Here is why teams absorb that cost.</p>
<h3>AI workload specialization</h3>
<p>No single cloud owns AI in 2026. A product team might use AWS Bedrock for orchestration, <a href="https://cloud.google.com/tpu/docs/v5e">GCP&#x27;s TPU v5e clusters</a> for model fine-tuning, and Azure&#x27;s OpenAI endpoints for enterprise data integration. Multi-cloud is what makes best-of-breed AI adoption possible.</p>
<h3>Geographic data sovereignty</h3>
<p>Product teams scaling globally face strict data residency laws, including GDPR and the wave of regional data mandates enacted through 2025. Running across AWS, Azure, and GCP gives you the flexibility to host data exactly where regulators require it.</p>
<h3>Cost arbitrage and contract leverage</h3>
<p>When you can genuinely shift workloads between providers, you change your negotiating position at contract renewal. The ability to migrate off a platform forces providers to compete on price. That leverage disappears the moment you are locked in.</p>
<table><thead><tr><th>Reason</th><th>Who benefits most</th></tr></thead><tbody><tr><td>AI workload specialization</td><td>Engineering, ML teams</td></tr><tr><td>Data sovereignty compliance</td><td>Product, Legal, Compliance</td></tr><tr><td>Cost arbitrage and negotiation</td><td>FinOps, Procurement</td></tr><tr><td>Vendor lock-in avoidance</td><td>Leadership, Architecture</td></tr></tbody></table>
<h2>Hub-and-spoke models across AWS, Azure, and GCP</h2>
<p>Designing a multi-cloud network requires understanding that the Big Three do not build infrastructure with the same philosophy. They all use a hub-and-spoke model at a high level. The execution, pricing, and developer experience are where they diverge.</p>
<h3>AWS: the autonomous network (Transit Gateway &amp; Cloud WAN)</h3>
<p>AWS favors a decentralized, builder-first model. Transit Gateway (TGW) acts as a regional hub router, connecting thousands of VPCs and on-premises networks under one control plane.</p>
<p><strong>The engineering view:</strong> <a href="https://aws.amazon.com/transit-gateway/">Transit Gateway (TGW)</a> is valued for its VRF-lite capabilities, with multiple isolated route tables that control traffic propagation with granular precision. You can build complex custom routing logic using Terraform and own every routing decision yourself.</p>
<p><strong>The FinOps view:</strong> TGW charges an hourly attachment fee plus a per-GB data processing fee. For high-throughput workloads like video streaming or AI data ingestion, this becomes a meaningful line item. AWS Cloud WAN abstracts global peering, but requires careful cost-benefit analysis before adoption.</p>
<h3>Azure: the managed enterprise (Virtual WAN)</h3>
<p>Azure optimizes for enterprise standardization. <a href="https://learn.microsoft.com/en-us/azure/virtual-wan/virtual-wan-about">Virtual WAN (vWAN)</a> provides a globally distributed, Microsoft-managed hub infrastructure, so you consume the hub rather than deploying your own.</p>
<p><strong>The engineering view:</strong> You give up granular, route-by-route control in exchange for native Azure Firewall and DDoS protection integration. It is highly opinionated but extremely stable, which suits teams that prioritize repeatability over flexibility.</p>
<p><strong>The FinOps view:</strong> vWAN reduces time-to-market for enterprise rollouts. The built-in security eliminates third-party network virtual appliance (NVA) procurement, consolidates billing, and simplifies compliance audits for ISO 27001 and SOC 2.</p>
<h3>GCP: the premium backbone (Network Connectivity Center)</h3>
<p>GCP prioritizes data-led delivery and engineering efficiency. <a href="https://cloud.google.com/network-connectivity/docs/network-connectivity-center/concepts/overview">Network Connectivity Center (NCC)</a> acts as a connectivity broker across your hybrid and multi-cloud topology.</p>
<p><strong>The engineering view:</strong> The standout feature is GCP&#x27;s Premium Tier networking. Traffic routes through Google&#x27;s private fiber backbone from the nearest edge PoP, bypassing the public internet entirely. For large dataset transfers across regions, such as model training jobs and analytics pipelines, this reduces packet loss and jitter measurably.</p>
<p><strong>The FinOps view:</strong> GCP networking is priced at a premium, but for latency-sensitive applications like real-time financial systems or multiplayer game backends, the performance ROI supports stricter SLA commitments to end users.</p>
<h2>Direct inter-cloud peering in 2026</h2>
<p>Historically, connecting AWS to GCP meant routing traffic through an on-premises data center or paying for colocation fabric providers like Equinix or Megaport. Physical cross-connects took weeks to provision. BGP sessions were configured manually.</p>
<p>That changed with jointly engineered, managed multi-cloud networking services, including AWS Interconnect multicloud and <a href="https://cloud.google.com/network-connectivity/docs/cross-cloud/concepts/overview">Google Cross-Cloud Interconnect</a>.</p>
<p>Today, architects can provision private, high-speed connections directly between AWS Direct Connect gateways and GCP Cloud Routers via API in minutes. These connections run with quad-redundancy and enforce <a href="https://docs.aws.amazon.com/directconnect/latest/UserGuide/macsec.html">MACsec (Media Access Control Security)</a> encryption at the physical layer.</p>
<h3>Validating the cross-cloud link</h3>
<p>Relying on vendor promises for cross-cloud encryption is not an acceptable posture. When establishing a direct peer between AWS and GCP, the BGP session state and MACsec encryption must be explicitly verified.</p>
<p>Below is validated terminal output demonstrating a healthy, encrypted BGP session from an AWS Virtual Interface to a GCP Cloud Router:</p>
<pre><code class="language-bash">$ aws directconnect describe-virtual-interfaces \
    --virtual-interface-id dxvif-fg5h8j9k \
    --query &#x27;virtualInterfaces[*].[bgpPeers[0].bgpStatus, macSecCapable, macSecKeys[0].state]&#x27; \
    --output table
</code></pre>
<pre><code class="language-bash">-----------------------------------------
|       DescribeVirtualInterfaces       |
+------+-------+------------------------+
|  up  |  True |  associated            |
+------+-------+------------------------+
</code></pre>
<pre><code class="language-bash">$ aws ec2 get-transit-gateway-route-table-propagations \
    --transit-gateway-route-table-id tgw-rtb-0abcd1234efgh5678 \
    --filters Name=resource-type,Values=direct-connect-gateway \
    --query &#x27;TransitGatewayRouteTablePropagations[*].{State:State, Route:TransitGatewayAttachmentId}&#x27;
</code></pre>
<pre><code class="language-json">[
  {
    &quot;State&quot;: &quot;enabled&quot;,
    &quot;Route&quot;: &quot;tgw-attach-0987654321fedcba&quot;
  }
]
</code></pre>
<p>What this confirms: the BGP session is up, routes are propagating correctly, and MACsec keys are actively associated, which enforces physical-layer encryption across the cloud boundary.</p>
<aside><p>If <code>macSecCapable</code> returns <code>False</code> or <code>macSecKeys[0].state</code> shows anything
other than <code>associated</code>, do not treat the link as secure. Re-provision the
Virtual Interface before routing sensitive traffic.</p></aside>
<h2>Security at the seams: a zero-trust mandate</h2>
<p>Connecting two large cloud environments effectively merges their attack surfaces. A vulnerability in an Azure web application could be used to exfiltrate data from an AWS S3 bucket if the network between them is flat.</p>
<p>Multi-cloud networking must be built on Zero-Trust Network Access (ZTNA) principles from the start.</p>
<div><section><p>Use private connectivity features (<a href="https://aws.amazon.com/privatelink/">AWS PrivateLink</a>, <a href="https://azure.microsoft.com/en-us/products/private-link/">Azure Private Link</a>, and <a href="https://cloud.google.com/vpc/docs/private-service-connect">GCP Private Service Connect</a>) so managed services appear as private IP addresses within your VPC. Traffic never touches the public internet, even when crossing cloud boundaries.</p></section><section><p>Workload-to-workload traffic crossing cloud boundaries must be continuously
inspected, not just perimeter traffic. Treat inter-cloud calls the same way
you treat inbound external traffic: require explicit allow rules and inspect
everything.</p></section><section><p>Network reachability is not the same as authorization. Every cross-cloud API call must be authenticated: AWS assumes IAM roles, GCP uses Workload Identity Federation, and Azure uses Managed Identities. Service accounts with broad static credentials are not acceptable at this layer.</p></section></div>
<p><img src="https://reclear.io/blog/2026-architects-guide-multi-cloud-connectivity/diagram/multi-cloud-connectivity-flow.webp" alt="Diagram: AWS workload via PrivateLink and Transit Gateway, MACsec direct peer to GCP Cloud Router, Private Service Connect to GCP workload, with shared identity validation"/></p>
<h2>Conclusion</h2>
<p>Multi-cloud connectivity in 2026 is not primarily a technical challenge. It is an alignment problem between engineering decisions and product requirements.</p>
<p>The right hub model depends on how much routing control your team needs, what your compliance obligations are, and which workloads justify premium networking costs. But the security model is not negotiable. Every connection across a cloud boundary must be private, encrypted, and identity-verified.</p>
<p>At <a href="https://reclear.io">Reclear</a>, we help engineering teams turn complex architectural decisions like these into clear, structured documentation that developers and product managers can actually use. If your team is building multi-cloud infrastructure and needs to document or communicate it effectively, <a href="https://reclear.io/contact">reach out</a>.</p>
<aside><p>If you&#x27;re scaling across AWS, Azure, or GCP and need technical content that
bridges the gap between your engineers and your business stakeholders, that is
exactly what we do at Reclear.</p></aside>
<p><a href="https://reclear.io/book-a-call">Work with us</a>: Book a call to discuss how Reclear can help document and communicate your multi-cloud architecture.</p>]]></content:encoded>
      <pubDate>Sun, 05 Apr 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Abioye Oyatoye)</author>
      <category>DeveloperExperience</category>
      <category>multicloud</category>
      <category>AWS</category>
      <category>azure</category>
      <enclosure url="https://reclear.io/blog/2026-architects-guide-multi-cloud-connectivity/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>What Is Idempotent in REST API Design?</title>
      <link>https://reclear.io/blog/what-is-idempotency-in-rest-api</link>
      <guid isPermaLink="true">https://reclear.io/blog/what-is-idempotency-in-rest-api</guid>
      <description>Learn why POST requests cause duplicate charges on retry, what idempotency keys do, and how to implement server-side deduplication in Node.js with PostgreSQL.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/what-is-idempotency-in-rest-api/cover/cover.webp" alt="What Is Idempotent in REST API Design?" /></p><aside><ul>
<li>Idempotency means repeating the same operation produces the same server state as running it once.</li>
<li><code>POST</code> is non-idempotent by spec, and network retries on <code>POST /charges</code> create duplicate charges without extra protection.</li>
<li>Idempotency keys fix this at the application layer: the client sends a UUID header, the server caches the response against it.</li>
<li>Implement deduplication with a dedicated database table and a unique constraint. The constraint handles the race condition atomically.</li>
</ul></aside>
<p>Your payment form has a spinner. The user clicks &quot;Pay.&quot; The network times out after three seconds. Your frontend retries. Stripe creates two charges.</p>
<p>This isn&#x27;t a hypothetical. Before Stripe formalized idempotency keys, this was a documented, recurring production failure, described explicitly in their 2017 engineering blog post because it was happening to real customers. The failure mode was always the same: a <code>POST /charges</code> that timed out, a retry that fired cleanly, and two line items on the card.</p>
<p>The fix isn&#x27;t &quot;don&#x27;t retry.&quot; Retries are correct. Networks are unreliable. The fix is designing your API so that retrying the same logical operation never produces a different result than running it once. That property is idempotency, and it&#x27;s the one most developers skip.</p>
<p>The fix is a server-side pattern called idempotency keys. This article explains the concept, builds the implementation in Node.js and PostgreSQL, and shows tests that prove the behavior against Stripe.</p>
<aside><p><strong>What you&#x27;ll need:</strong> Node.js 20+, PostgreSQL 14+, Express 4.x or 5.x (middleware examples follow 4.x style), and a Stripe test account if you run the validation suite against test mode.</p></aside>
<h2>Why POST retries create duplicate charges</h2>
<p>A <code>POST</code> can be executed more than once <strong>without the client doing anything wrong</strong>. Common cases:</p>
<table><thead><tr><th>What went wrong on the wire</th><th>What the client does next</th></tr></thead><tbody><tr><td>Timeout before any response is read</td><td>Retries</td></tr><tr><td>Success response lost in transit</td><td>Retries</td></tr><tr><td>App crashed after send, before it saved &quot;success&quot; locally</td><td>Retries on restart</td></tr></tbody></table>
<p>Same outcome: the server may see <strong>two logical attempts</strong> at one user action. None of these are client bugs.</p>
<p>Naive retry (no idempotency key on the request):</p>
<pre><code class="language-js">// Node.js 20: client-side retry without idempotency protection
async function chargeCustomer(amount, customerId) {
  for (let attempt = 1; attempt &lt;= 3; attempt++) {
    try {
      const response = await fetch(&quot;/api/charges&quot;, {
        method: &quot;POST&quot;,
        headers: { &quot;Content-Type&quot;: &quot;application/json&quot; },
        body: JSON.stringify({ amount, customer_id: customerId }),
      });
      return await response.json();
    } catch (err) {
      if (attempt === 3) throw err;
      await new Promise((r) =&gt; setTimeout(r, attempt * 1000));
    }
  }
}
</code></pre>
<p>The loop is fine. The server is the problem: every <code>POST /charges</code> creates a new charge. Three retries can mean three charges.</p>
<h2>What idempotent means in REST API design</h2>
<p><a href="https://www.rfc-editor.org/rfc/rfc9110#section-9.2.2">RFC 9110</a> (Section 9.2.2):</p>
<blockquote>
<p>&quot;A request method is considered &#x27;idempotent&#x27; if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request.&quot;</p>
</blockquote>
<p>What that implies in practice:</p>
<ul>
<li><strong>Server state</strong>, not identical response bodies. Example: first <code>DELETE /users/42</code> → <code>204</code>; repeat → <code>404</code>. User is still gone; state matches &quot;deleted once.&quot;</li>
<li><strong>Semantics over mechanics:</strong> you design for what the operation <em>means</em>, not only what the handler does line-by-line.</li>
</ul>
<p>Idempotent by spec: <code>GET</code>, <code>HEAD</code>, <code>PUT</code>, <code>DELETE</code>, <code>OPTIONS</code>, <code>TRACE</code>. <strong><code>POST</code> is not.</strong> That is why idempotency <strong>keys</strong> exist as an application-layer pattern on top of <code>POST</code>.</p>
<img src="https://reclear.io/blog/what-is-idempotency-in-rest-api/diagram/idempotency-key-flow-clean-branded.webp" alt="Sequence diagram: client POST with Idempotency-Key, server checks DB, creates Stripe charge, stores response; retry after network failure returns cached 201 without Stripe" width="2944" height="1728"/>
<p><em>The idempotency key flow: the second request returns the cached response without touching Stripe.</em></p>
<p>On the first request the server processes the operation and stores the response against the key. Every subsequent request with that key gets the cached response back without re-running the processor.</p>
<p><strong>Stripe (reference behavior):</strong></p>
<ul>
<li>Reused <code>Idempotency-Key</code> → <strong>same</strong> cached response, <strong>same</strong> HTTP status (e.g. cached <code>402</code> stays <code>402</code>).</li>
<li>Mappings kept <strong>≥ 24 hours</strong>; after that, the same string is a new logical request.</li>
<li>Keys scoped per account; <code>(key, endpoint, params)</code> fingerprint → wrong params → <code>400</code>.</li>
<li>Two identical in-flight calls can surface as <code>409</code>.</li>
<li>Key format: UUID v4 recommended (<code>crypto.randomUUID()</code> in Node); max length per Stripe docs.</li>
</ul>
<h2>Implementing idempotency keys on the server</h2>
<p><strong>Stack:</strong> Node.js (Express + PostgreSQL).</p>
<p><strong>Step 1: Create the idempotency keys table</strong></p>
<p>Dedicated table; pattern aligns with production billing key stores (see e.g. Brandur Leach on idempotency at Stripe).</p>
<pre><code class="language-sql">CREATE TABLE idempotency_keys (
  id              BIGSERIAL PRIMARY KEY,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  idempotency_key TEXT NOT NULL,
  last_run_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  locked_at       TIMESTAMPTZ,
  recovery_point  TEXT NOT NULL DEFAULT &#x27;started&#x27;,
  request_method  TEXT NOT NULL,
  request_path    TEXT NOT NULL,
  request_params  JSONB NOT NULL,
  response_code   INT,
  response_body   JSONB,
  user_id         BIGINT NOT NULL,
  expires_at      TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL &#x27;24 hours&#x27;),
  UNIQUE(user_id, idempotency_key)
);

CREATE INDEX ON idempotency_keys (expires_at);
</code></pre>
<table><thead><tr><th>Mechanism</th><th>Role</th></tr></thead><tbody><tr><td><code>UNIQUE(user_id, idempotency_key)</code></td><td>Atomic dedupe; races resolved in the DB</td></tr><tr><td><code>recovery_point</code></td><td>Tracks how far a multi-step operation got</td></tr><tr><td><code>locked_at</code></td><td>Available for external lock tracking; not set by this implementation</td></tr><tr><td>Index on <code>expires_at</code></td><td>Cheap cleanup of expired rows</td></tr></tbody></table>
<p><strong>Step 2: Acquire and complete keys in PostgreSQL</strong></p>
<p>Database-backed, concurrency-safe acquisition:</p>
<pre><code class="language-js">// Node.js 20: PostgreSQL idempotency key store (pg@8)
import pg from &quot;pg&quot;;
import { isDeepStrictEqual } from &quot;node:util&quot;;
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

export async function acquireIdempotencyKey(userId, key, method, path, params) {
  const client = await pool.connect();
  try {
    await client.query(&quot;BEGIN&quot;);
    const requestParams = params ?? {};

    const insert = await client.query(
      `INSERT INTO idempotency_keys
         (user_id, idempotency_key, request_method, request_path, request_params)
       VALUES ($1, $2, $3, $4, $5)
       ON CONFLICT (user_id, idempotency_key) DO NOTHING
       RETURNING *`,
      [userId, key, method, path, JSON.stringify(requestParams)],
    );

    if (insert.rows.length &gt; 0) {
      // We inserted; we own this request
      await client.query(&quot;COMMIT&quot;);
      return { status: &quot;new&quot;, record: insert.rows[0] };
    }

    // Key already exists; fetch it with a lock
    const existing = await client.query(
      `SELECT * FROM idempotency_keys
       WHERE user_id = $1 AND idempotency_key = $2
       FOR UPDATE NOWAIT`,
      [userId, key],
    );

    await client.query(&quot;COMMIT&quot;);
    const record = existing.rows[0];

    // Params mismatch; reject
    if (!isDeepStrictEqual(record.request_params, requestParams)) {
      return { status: &quot;conflict&quot; };
    }

    // Already completed; return cached response
    if (record.response_code !== null) {
      return { status: &quot;cached&quot;, record };
    }

    // In-flight; signal the client to retry
    return { status: &quot;in_progress&quot; };
  } catch (err) {
    await client.query(&quot;ROLLBACK&quot;);
    // FOR UPDATE NOWAIT throws 55P03 if the row is already locked
    if (err.code === &quot;55P03&quot;) {
      return { status: &quot;in_progress&quot; };
    }
    throw err;
  } finally {
    client.release();
  }
}

export async function completeIdempotencyKey(
  userId,
  key,
  responseCode,
  responseBody,
) {
  await pool.query(
    `UPDATE idempotency_keys
     SET response_code = $3,
         response_body = $4,
         recovery_point = &#x27;completed&#x27;,
         last_run_at    = now()
     WHERE user_id = $1 AND idempotency_key = $2`,
    [userId, key, responseCode, JSON.stringify(responseBody)],
  );
}
</code></pre>
<table><thead><tr><th>Behavior</th><th>Why</th></tr></thead><tbody><tr><td><code>COMMIT</code> before returning from acquisition</td><td>Row lock does <strong>not</strong> wrap your handler or Stripe</td></tr><tr><td><code>response_code === null</code> → <code>in_progress</code> (409)</td><td>Dedupes slow work while another request is in flight</td></tr><tr><td><code>FOR UPDATE NOWAIT</code> → <code>55P03</code> → <code>in_progress</code></td><td>Fail-fast when two transactions race at acquisition time</td></tr></tbody></table>
<aside><p><strong>Do not</strong> read <code>FOR UPDATE NOWAIT</code> as “the row stays locked through payment.” After <code>COMMIT</code>, the row is unlocked. Concurrent requests during Stripe still see <code>response_code === null</code> and receive <strong>409</strong> <code>in_progress</code>; <code>NOWAIT</code> matters when two transactions contend <strong>during acquisition</strong>, not while the processor call is in flight.</p></aside>
<p><strong>Step 3: Express middleware</strong></p>
<ul>
<li>Validate UUID v4 <code>Idempotency-Key</code>; require authenticated <code>userId</code>.</li>
<li><code>cached</code> → replay stored status + body; <code>conflict</code> → 400; <code>in_progress</code> → 409; <code>new</code> → wrap <code>res.json</code> and fire-and-forget <code>completeIdempotencyKey</code> so the client is not blocked.</li>
</ul>
<pre><code class="language-js">// Node.js 20: Express idempotency middleware
import {
  acquireIdempotencyKey,
  completeIdempotencyKey,
} from &quot;../db/idempotency.js&quot;;

const UUID_V4 =
  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

export function idempotencyMiddleware() {
  return async (req, res, next) =&gt; {
    const key = req.headers[&quot;idempotency-key&quot;];

    // Idempotency is opt-in; skip if no key
    if (!key) return next();

    if (!UUID_V4.test(key)) {
      return res.status(400).json({
        error: &quot;invalid_idempotency_key&quot;,
        message: &quot;Idempotency-Key must be a valid UUID v4.&quot;,
      });
    }

    const userId = req.user?.id;
    if (!userId) {
      return res.status(401).json({
        error: &quot;unauthorized&quot;,
        message: &quot;Authentication is required before idempotency checks.&quot;,
      });
    }

    const result = await acquireIdempotencyKey(
      userId,
      key,
      req.method,
      req.path,
      req.body,
    );

    switch (result.status) {
      case &quot;cached&quot;:
        return res
          .status(result.record.response_code)
          .json(result.record.response_body);

      case &quot;conflict&quot;:
        return res.status(400).json({
          error: &quot;idempotency_key_reuse&quot;,
          message: &quot;This key was used with different request parameters.&quot;,
        });

      case &quot;in_progress&quot;:
        return res.status(409).json({
          error: &quot;request_in_progress&quot;,
          message:
            &quot;A request with this key is already being processed. Retry shortly.&quot;,
        });

      case &quot;new&quot;: {
        // Intercept res.json to capture the response for storage (sync override; persistence is fire-and-forget)
        const originalJson = res.json.bind(res);
        res.json = (body) =&gt; {
          completeIdempotencyKey(userId, key, res.statusCode, body).catch(
            (err) =&gt; {
              console.error(&quot;Idempotency completion failed:&quot;, err);
            },
          );
          return originalJson(body);
        };
        return next();
      }
    }
  };
}
</code></pre>
<p><strong>Step 4: Charge route and Stripe</strong></p>
<p>Pass the same key to Stripe so you have <strong>app-level</strong> dedupe plus <strong>processor-level</strong> dedupe.</p>
<pre><code class="language-js">// Node.js 20: Express charge route with layered idempotency
import express from &quot;express&quot;;
import Stripe from &quot;stripe&quot;;
import { idempotencyMiddleware } from &quot;../middleware/idempotency.js&quot;;

const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

router.post(&quot;/charges&quot;, idempotencyMiddleware(), async (req, res) =&gt; {
  const { amount, currency, payment_method_id } = req.body;

  const paymentIntent = await stripe.paymentIntents.create(
    { amount, currency, payment_method: payment_method_id, confirm: true },
    // Pass your key to Stripe as a second layer of deduplication
    { idempotencyKey: req.headers[&quot;idempotency-key&quot;] },
  );

  res.status(201).json({
    id: paymentIntent.id,
    status: paymentIntent.status,
    amount: paymentIntent.amount,
  });
});

export default router;
</code></pre>
<h2>Verifying idempotency keys in Stripe test mode</h2>
<ol>
<li>Confirm TTL, replay, and mismatch behavior against <strong>current Stripe docs</strong> for your API version.</li>
<li>Run the API on <code>http://localhost:3000</code> with <code>TEST_TOKEN</code> set for the test below.</li>
<li>From project root: <code>node --test test/idempotency.test.js</code></li>
</ol>
<pre><code class="language-js">// Node.js 20 built-in test runner
import { describe, it } from &quot;node:test&quot;;
import assert from &quot;node:assert/strict&quot;;
import crypto from &quot;node:crypto&quot;;

const BASE_URL = &quot;http://localhost:3000&quot;;

async function charge(idempotencyKey, amount = 4999) {
  const res = await fetch(`${BASE_URL}/charges`, {
    method: &quot;POST&quot;,
    headers: {
      &quot;Content-Type&quot;: &quot;application/json&quot;,
      Authorization: `Bearer ${process.env.TEST_TOKEN}`,
      &quot;Idempotency-Key&quot;: idempotencyKey,
    },
    body: JSON.stringify({
      amount,
      currency: &quot;usd&quot;,
      payment_method_id: &quot;pm_card_visa&quot;, // Stripe test payment method
    }),
  });
  return { status: res.status, body: await res.json() };
}

describe(&quot;Idempotency middleware&quot;, () =&gt; {
  it(&quot;returns the same charge ID for duplicate keys&quot;, async () =&gt; {
    const key = crypto.randomUUID();
    const first = await charge(key);
    const second = await charge(key);

    assert.equal(first.status, 201);
    assert.equal(second.status, 201);
    assert.equal(first.body.id, second.body.id); // same charge, not a new one
  });

  it(&quot;returns 409 for concurrent duplicate requests, exactly one succeeds&quot;, async () =&gt; {
    const key = crypto.randomUUID();
    const results = await Promise.all([charge(key), charge(key), charge(key)]);

    const successes = results.filter((r) =&gt; r.status === 201);
    const conflicts = results.filter((r) =&gt; r.status === 409);

    assert.equal(successes.length, 1);
    assert.ok(conflicts.length &gt;= 1);
    // All successful responses reference the same charge
    assert.equal(new Set(successes.map((r) =&gt; r.body.id)).size, 1);
  });

  it(&quot;returns 400 for parameter mismatch on same key&quot;, async () =&gt; {
    const key = crypto.randomUUID();
    await charge(key, 4999); // establish the key
    const mismatch = await charge(key, 9999); // different amount

    assert.equal(mismatch.status, 400);
    assert.equal(mismatch.body.error, &quot;idempotency_key_reuse&quot;);
  });
});
</code></pre>
<p>Example output (timings vary):</p>
<pre></pre>
<table><thead><tr><th>Test</th><th>What it proves</th></tr></thead><tbody><tr><td>Same key, sequential</td><td>Cached response path</td></tr><tr><td>Same key, <code>Promise.all</code></td><td><code>UNIQUE</code> + acquisition race + <code>in_progress</code> while work runs</td></tr><tr><td>Same key, different amount</td><td>Parameter fingerprint → 400</td></tr></tbody></table>
<h2>When not to use idempotency keys</h2>
<table><thead><tr><th>Situation</th><th>Reason</th></tr></thead><tbody><tr><td><code>GET</code> / <code>PUT</code> / <code>DELETE</code> etc. are already spec-idempotent</td><td>Keys add cost with no gain</td></tr><tr><td><code>POST</code> only mutates your DB, no payments/email/webhooks</td><td>A business unique key (<code>order_number</code>, …) is often simpler</td></tr><tr><td>Analytics / audit / high-volume append-only <code>POST /events</code></td><td>You usually <strong>want</strong> every event, not dedupe</td></tr><tr><td>Retry window longer than your key TTL (often 24h-style)</td><td>Same key string becomes a new operation after expiry: extend TTL or dedupe by business id</td></tr></tbody></table>
<h2>How other APIs handle idempotency keys</h2>
<table><thead><tr><th>Provider</th><th>Typical header</th><th>Check in docs</th></tr></thead><tbody><tr><td>Stripe</td><td><code>Idempotency-Key</code></td><td>Status replay, TTL, mismatch vs in-flight</td></tr><tr><td>PayPal</td><td><code>PayPal-Request-Id</code></td><td>Omitting header may still process duplicates</td></tr><tr><td>Adyen</td><td><code>Idempotency-Key</code></td><td>Length limits, replay rules vs Stripe</td></tr></tbody></table>
<ul>
<li><strong>Conditional requests</strong> (<code>ETag</code> / <code>If-Match</code>) on some APIs (e.g. <a href="https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api?apiVersion=2022-11-28">GitHub REST</a>) solve <strong>concurrency</strong>, not the same problem as payment <code>POST</code> keys. Same family of “safe retries,” different tool.</li>
<li><strong><a href="https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/">IETF <code>Idempotency-Key</code> header draft</a>.</strong> Until it is widely adopted, assert <strong>your</strong> provider’s contract in tests, not a generic table row.</li>
</ul>]]></content:encoded>
      <pubDate>Wed, 01 Apr 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Wale Bashir)</author>
      <category>Tutorials</category>
      <category>DeveloperExperience</category>
      <category>Documentation</category>
      <enclosure url="https://reclear.io/blog/what-is-idempotency-in-rest-api/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>How to Write Developer Documentation That Actually Gets Used</title>
      <link>https://reclear.io/blog/developer-documentation-that-gets-used</link>
      <guid isPermaLink="true">https://reclear.io/blog/developer-documentation-that-gets-used</guid>
      <description>Learn how to write developer documentation that developers actually use with clear structure, working examples, and a practical process.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/developer-documentation-that-gets-used/cover/cover.webp" alt="How to Write Developer Documentation That Actually Gets Used" /></p><aside><ul>
<li>Most developer documentation gets ignored because it is structured around features instead of real tasks</li>
<li>Developers use documentation to complete actions, so content should guide them from first success to real usage</li>
<li>Use a clear structure like Diátaxis to separate tutorials, guides, reference, and explanations</li>
<li>Start with working examples and maintain documentation alongside your product to keep it useful</li>
</ul></aside>
<p>You may have seen this before.</p>
<p>A developer lands on your documentation with a specific purpose. They want to know how your system fits into their stack, make their first API request, or add a feature. Instead, they get a wall of text, broken links, and code snippets that don&#x27;t work. They leave ten minutes later.</p>
<p>This is the reality for most teams trying to write developer documentation that gets used. The problem is not usually effort. It&#x27;s the structure. Instead of showing how developers really work, documentation is written about how products are set up.</p>
<p>This article explains how to write developer documentation that developers will actually use. It focuses on organizing information around real tasks, making documentation easy to find and use, and creating a system that stays accurate as your product changes.</p>
<h2>Why most developer documentation gets ignored</h2>
<p>Most of the time, documentation isn&#x27;t written for how developers really work. It is written in the way that the product is set up.</p>
<p>Internally, teams talk about APIs, features, and services. The documentation follows that structure. Users, billing, webhooks, and authentication. But that&#x27;s not how a developer looks at your docs. They come with a task in mind. Make a request. Ship a feature. Fix something that is broken. When documentation makes them turn the structure of a product into a usable workflow, it slows them down right away.</p>
<p>This is why developers often don&#x27;t use documentation even though it seems complete. The problem is rarely effort. Teams put in the work. The problem is that the documentation is better for organizing than for executing.</p>
<p>At a high level, the difference looks like this:</p>
<table><thead><tr><th>Bad documentation</th><th>Good documentation</th></tr></thead><tbody><tr><td>Feature-focused</td><td>Task-focused</td></tr><tr><td>Explanation first</td><td>Working example first</td></tr><tr><td>Scattered information</td><td>Clear navigation paths</td></tr><tr><td>Partial or broken code</td><td>Complete, runnable examples</td></tr><tr><td>Written once, rarely updated</td><td>Maintained alongside the product</td></tr></tbody></table>
<p>Here are the most common patterns that cause developers to drop off:</p>
<h3>1. Docs are written around features not tasks</h3>
<p>Many docs are structured like a product map instead of a user journey.</p>
<p>You see sections like:</p>
<ul>
<li>Authentication</li>
<li>Users API</li>
<li>Billing API</li>
</ul>
<p>However, developers don&#x27;t think in terms of features. They think in terms of tasks. They want to know how to do something specific with your product like:</p>
<ul>
<li>Adding login to an app</li>
<li>Creating a new user in the system</li>
<li>Charging a customer for a service</li>
</ul>
<p>When documentation does not map directly to these tasks, the developer has to figure out the flow themselves. That extra effort is often enough to make them leave.</p>
<h3>2. Too much explanation not enough execution</h3>
<p>Some documentation tells you everything but doesn&#x27;t help you do anything.</p>
<p>Long paragraphs explain how a system works, but the developer still doesn&#x27;t know:</p>
<ul>
<li>What do I run first</li>
<li>What request should I make</li>
<li>What does a working example look like</li>
</ul>
<p>Good documentation starts with action. Explanations support it, not the other way around.</p>
<h3>3. Broken or incomplete examples</h3>
<p>See, nothing breaks trust faster than code that doesn&#x27;t work. Here are some common issues:</p>
<ul>
<li>Missing headers or authentication steps</li>
<li>Placeholder values with no explanation</li>
<li>Snippets that cannot run on their own</li>
<li>No expected output shown</li>
</ul>
<p>A developer should be able to copy, run, and see a result. If they cannot, the documentation stops being reliable.</p>
<p>For example, here is a code block that is incomplete:</p>
<pre><code class="language-bash">POST /api/v1/users
</code></pre>
<p>This tells the developer that the endpoint <code>/api/v1/users</code> exists, but it doesn&#x27;t tell them what to do with it.</p>
<p>Instead, the working example should look like this:</p>
<pre><code class="language-bash">curl -X POST https://api.example.com/v1/users \
  -H &quot;Authorization: Bearer sk_test_123456&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &#x27;{
    &quot;email&quot;: &quot;user@example.com&quot;,
    &quot;name&quot;: &quot;John Doe&quot;
  }&#x27;
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  &quot;id&quot;: &quot;user_123456&quot;,
  &quot;email&quot;: &quot;user@example.com&quot;,
  &quot;name&quot;: &quot;John Doe&quot;
}
</code></pre>
<p>Now the developer has everything needed:</p>
<ul>
<li>The endpoint to call</li>
<li>The authentication method</li>
<li>The request body</li>
<li>The expected response</li>
</ul>
<aside><p><strong>A good rule is simple:</strong> if a developer cannot copy your example and get a result, it is not documentation. It is a description.</p></aside>
<h3>4. Poor navigation and structure</h3>
<p>Even when the content is good, structure can make it hard to use. Common signals of poor structure includes:</p>
<ul>
<li>No clear starting point</li>
<li>No obvious next step</li>
<li>Related concepts scattered across multiple pages</li>
<li>No distinction between tutorials, guides, and reference</li>
</ul>
<p>Developers do not read documentation linearly. They scan, jump, and search. If they cannot quickly find what they need, they move on.</p>
<h3>5. Documentation drifts away from the product</h3>
<p>Documentation is often written once and not maintained with the same discipline as code. Because over time:</p>
<ul>
<li>Endpoints change</li>
<li>Parameters are added or removed</li>
<li>Behavior evolves</li>
</ul>
<p>But the documentation stays the same. This creates a gap between what is written and what actually works. Once developers hit that gap, trust drops quickly.</p>
<p>Most teams do not struggle because they do not care about documentation. They struggle because they are writing from the inside out. The shift is simple, but not easy. Write documentation that developers can use, not that is based on how your product is set up.</p>
<h2>How do developers actually approach your documentation?</h2>
<p>Most developers don&#x27;t look at your documentation to learn more about your product. They come to do something.</p>
<p>They know exactly what they want to do. Make the first API request. Add an authentication method. Fix an issue. They don&#x27;t want to know everything about your system. They are trying to go forward. When documentation isn&#x27;t organized around that goal, it causes problems right away.</p>
<p>This is where most documentation goes wrong. It assumes that developers will read parts and put them together. In reality, developers follow a path. They look for the quickest way to get to a working result and then build on that.</p>
<p>At a high level, this is what that path looks like:</p>
<p><img src="https://reclear.io/blog/developer-documentation-that-gets-used/diagram/developer-documentation-user-journey.webp" alt="Flow from landing on docs through quickstart, first successful request, real use case, advanced usage, and troubleshooting"/></p>
<p>Developers do not explore documentation randomly. They move step by step toward a working outcome.</p>
<p><strong>1. Developers think in tasks not features</strong></p>
<p>Internally, your product may be organized around services or features. However, developers think in terms of outcomes. They are asking questions like:</p>
<ul>
<li>How do I authenticate a user?</li>
<li>How do I create a resource?</li>
<li>How do I handle errors?</li>
</ul>
<p>Not:</p>
<ul>
<li>Where is the authentication service</li>
<li>What endpoints exist under users</li>
</ul>
<p>When features are documented, developers have to turn that information into tasks on their own. That extra step makes them go slower and raises the chance that they will drop off.</p>
<p><strong>2. Different developers need different entry points</strong></p>
<p>Not every developer comes to your documentation in the same state.</p>
<p>Some are:</p>
<ul>
<li>Giving your product a try for the first time</li>
<li>Already working on something and need a specific answer</li>
<li>Fixing a problem in production</li>
</ul>
<p>Each of these situations needs a different amount of help.</p>
<ul>
<li>A new developer needs a clear place to start.</li>
<li>A builder who is working needs clear instructions.</li>
<li>An advanced user needs a clear reference</li>
</ul>
<p>If your documentation treats all of them the same, it doesn&#x27;t help any of them.</p>
<p><strong>3. Good documentation follows a clear path</strong></p>
<p>Good documentation doesn&#x27;t make developers figure things out on their own. Instead, it guides them through a natural progression.</p>
<ul>
<li>Start with a quick way to make something work.</li>
<li>Move to common use cases</li>
<li>Give more options for configuration and flexibility</li>
<li>Help troubleshooting when things break</li>
</ul>
<p>Each step builds on the previous one.</p>
<p>When this path is clear:</p>
<ul>
<li>Developers move faster</li>
<li>Confidence builds quickly</li>
<li>Your product feels easier to adopt</li>
</ul>
<p>Nevertheless, when this path isn&#x27;t clear:</p>
<ul>
<li>Developers switch between pages</li>
<li>They lose their place.</li>
<li>They leave before they get anything of value.</li>
</ul>
<p>If you set up your documentation the way developers actually use it, everything else will be easier. Navigation gets better. Examples help things make more sense. And developers spend less time figuring things out and more time making things.</p>
<h2>What documentation structure works best for developers?</h2>
<p>Once you understand how developers approach your documentation, the next step is structuring it in a way they can navigate quickly.</p>
<p>The <a href="https://diataxis.fr/">Diátaxis framework</a> is one of the best ways to do this. It sorts documentation into four groups based on what the developer wants to do. Each type has its own page instead of mixing everything together.</p>
<p>Most documentation problems come from mixing these types. You see tutorials inside API references, or long explanations inside task guides. That makes it harder for developers to find what they need.</p>
<p>Diátaxis makes it clear what each piece of documentation is for.</p>
<p>Here is the four-part model for organizing documentation:</p>
<h3>Tutorials for first success</h3>
<p>Tutorials are for new developers getting started.</p>
<p>They are step by step and assume little to no prior knowledge. The goal is to help someone achieve a complete outcome from start to finish.</p>
<p><strong>Example:</strong> &quot;Build your first API integration in 10 minutes&quot;</p>
<p>A good tutorial has a clear starting point, walks through every step, and leads to a working result.</p>
<h3>How-to guides for specific tasks</h3>
<p>How-to guides are focused on solving a specific problem.</p>
<p>They assume the developer already understands the basics and just needs to complete a task.</p>
<p><strong>Example:</strong> “How to set up webhook notifications”</p>
<p>A good how-to guide is goal-driven, skips unnecessary explanations, and shows only what is needed to complete the task</p>
<h3>Reference docs for precision</h3>
<p>Reference documentation is where developers go for exact details.</p>
<p>It includes:</p>
<ul>
<li>API endpoints</li>
<li>parameters</li>
<li>request and response formats</li>
<li>configuration options</li>
</ul>
<p><strong>Example:</strong> “POST /v1/users” API reference</p>
<p>Reference docs are not meant to teach. They are meant to be complete, consistent, and easy to search</p>
<h3>Explanation docs for understanding</h3>
<p>Explanation content helps developers understand how your system works.</p>
<p>This is where you cover:</p>
<ul>
<li>architecture</li>
<li>design decisions</li>
<li>tradeoffs</li>
</ul>
<p>Example: “How our authentication system works”</p>
<p>These pages help developers build a mental model of your product.</p>
<h2>How to write technical documentation: a step-by-step process</h2>
<p>Once you understand how developers use documentation and how it should be structured, the next step is applying it in practice.</p>
<p>Writing good documentation is not about covering everything at once. It is about following a process that keeps your content focused, usable, and aligned with real developer needs.</p>
<div><section><p>Start with a clear outcome.</p><p>Developers do not open documentation to explore your system. They come with a specific goal in mind. It might be making their first API request, adding authentication, or fixing an issue. That goal should guide what you write.</p><p>If you cannot describe what the developer will achieve in a single sentence, the documentation is likely too broad.</p></section><section><p>Before writing, decide what kind of content you are creating.</p><p>Using the Diátaxis framework, your content should fall into one of four types: tutorials, how-to guides, reference, or explanation. Each serves a different purpose, and mixing them often creates confusion.</p><p>At a high level, your documentation navigation might look like this:</p><ul>
<li>Getting Started (tutorials)</li>
<li>Guides (how-to content)</li>
<li>API Reference (reference)</li>
<li>Concepts (explanation)</li>
</ul><p>This structure works because it matches how developers move through documentation. They begin with a tutorial, move to guides for specific tasks, rely on reference while building, and read concepts when they need deeper understanding.</p></section><section><p>The fastest way to build trust is to show something that works.</p><p>Instead of starting with long explanations, begin with a minimal example the developer can run. This gives them immediate feedback and confidence that they are on the right path.</p><pre><code class="language-bash">curl -X POST https://api.example.com/v1/users \
  -H &quot;Authorization: Bearer sk_test_123456&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &#x27;{
    &quot;email&quot;: &quot;user@example.com&quot;
  }&#x27;
</code></pre><p>A working example reduces friction. It shows what success looks like before diving into details.</p></section><section><p>Once the example is clear, add context that helps the developer understand what is happening.</p><p>Focus on what they need to know to move forward. Explain why certain fields are required, what can be changed, and what to expect from the response. Avoid long background explanations that do not directly support the task.</p><p>Good documentation does not explain everything. It explains what is necessary at the right moment.</p></section><section><p>Developers rarely read documentation from start to finish. They scan for answers.</p><p>This means your content should be easy to navigate at a glance. Clear headings, short paragraphs, and well-separated sections make it easier to find what matters. The goal is to help someone locate and understand the right piece of information without reading the entire page.</p></section><section><p>Before publishing, use your documentation the way a developer would.</p><p>Follow the steps from start to finish. Check if anything is unclear, missing, or assumed. If possible, have someone unfamiliar with the product try it. Gaps become obvious when someone else relies on your instructions.</p><p>Documentation that has not been tested often fails in small but important ways.</p></section><section><p>Documentation is not a one-time effort.</p><p>As your product evolves, your documentation needs to evolve with it. Endpoints change, parameters are updated, and new features are introduced. If the documentation does not reflect those changes, trust breaks quickly.</p><p>Treat documentation as part of your development process. Update it alongside code changes and review it regularly to keep it accurate.</p></section></div>
<h2>Choosing the right documentation tool</h2>
<p>Once you have a clear structure and process, the next step is choosing the right tool to support it.</p>
<p>A documentation tool won&#x27;t help if your writing is unclear or your structure is bad. But the right one can make it easier to maintain, scale, and collaborate on your documentation over time.</p>
<p>Most modern teams use a docs-as-code approach, which means that documentation is stored with the codebase and updated in the same way. This makes it easier to keep documentation in sync with product changes and allows engineers to contribute using tools they already use.</p>
<p>When you look at a documentation tool, pay attention to how well it fits into your workflow.</p>
<p>Check for:</p>
<ul>
<li>Markdown or MDX support</li>
<li>Version control and Git integration</li>
<li>Fast and reliable search</li>
<li>Clear navigation and content organization</li>
<li>Workflows for collaboration and review</li>
</ul>
<p>The goal is not only to publish documentation, but also to keep it up to date as your product changes.</p>
<p>There are several tools that support this approach:</p>
<ul>
<li><a href="https://www.mintlify.com/">Mintlify</a> has built-in components and Git sync, making it a modern documentation tool for developers.</li>
<li><a href="https://docusaurus.io/">Docusaurus</a> is an open source option that lets you have more control and flexibility over your documentation site.</li>
<li><a href="https://www.gitbook.com/">GitBook</a> has a structured interface that works well for teams with both technical and non-technical members.</li>
</ul>
<p>Some teams also build documentation using frameworks like <a href="https://astro.build/">Astro</a> or <a href="https://gohugo.io/">Hugo</a>, especially when they want more control over performance, customization, or deployment workflows.</p>
<p>Each tool has its pros and cons, so you should choose the one that works best for your team, your workflow, and how much control you need.</p>
<p>In the end, how you use the tool is more important than the tool itself. The platform you choose won&#x27;t matter as much as having a clear structure, working examples, and a consistent process.</p>
<h2>Conclusion</h2>
<p>Good developer documentation is not about covering every detail. It is about helping developers move forward.</p>
<p>When documentation is structured around real tasks, supported with working examples, and maintained alongside the product, it becomes part of the developer experience. It reduces friction, builds trust, and makes your product easier to adopt.</p>
<p>The difference is not in how much you write. It is in how usable your documentation is when it matters.</p>
<p>At <a href="https://reclear.io">Reclear</a>, this is the approach we take. We help teams create technical documentation and content that developers can actually use, not just read.</p>
<p>If you are looking to improve your documentation or build it from scratch, you can <a href="https://reclear.io/contact">reach out</a> to see how we can help.</p>
<aside><p>If you&#x27;re looking for help turning complex technical products into clear, developer-friendly content, that&#x27;s exactly what we do at Reclear.</p></aside>
<p><a href="https://reclear.io/book-a-call">Work with us</a>: Book a call to discuss how we can help with your developer documentation and technical content.</p>]]></content:encoded>
      <pubDate>Tue, 31 Mar 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Olusegun Durojaye)</author>
      <category>Documentation</category>
      <category>DX</category>
      <category>TechnicalWriting</category>
      <enclosure url="https://reclear.io/blog/developer-documentation-that-gets-used/cover/cover.webp" type="image/webp" length="0" />
    </item>
    <item>
      <title>Why Your Developer Tutorials Aren&apos;t Converting and How to Fix Them</title>
      <link>https://reclear.io/blog/why-developer-tutorials-arent-converting</link>
      <guid isPermaLink="true">https://reclear.io/blog/why-developer-tutorials-arent-converting</guid>
      <description>Learn why your developer tutorials aren’t converting and how to fix them with faster results, clear outcomes, and practical examples.</description>
      <content:encoded><![CDATA[<p><img src="https://reclear.io/blog/why-developer-tutorials-arent-converting/cover/cover.webp" alt="Why Your Developer Tutorials Aren&apos;t Converting and How to Fix Them" /></p><aside><ul>
<li>Developer tutorials fail to convert because they delay the first working result and focus too much on explanation instead of execution</li>
<li>Conversion in devtools happens when a developer gets something working, not when they understand the product</li>
<li>High-converting tutorials optimize for Time to First Success (TTFS) with clear outcomes, minimal setup, and runnable examples</li>
<li>The best tutorials guide developers to a quick win, reduce friction, and are continuously improved based on real usage</li>
</ul></aside>
<p>A developer comes to your tutorial with a specific goal in mind. They want to try your product and get something working quickly. A few steps in, they are still setting things up, reading through explanations, and trying to make sense of everything. There is no clear result yet, no small win to confirm they are on the right path. After a while, they give up and leave. This is where most tutorials fail. Not because they are wrong, but because they do not lead to a quick win.</p>
<p>This article breaks down why developer tutorials do not convert and how to fix them. It explores where most tutorials go wrong, what high-converting tutorials do differently, and how to design tutorials that guide developers from first interaction to real success.</p>
<h2>Developer tutorials are your real conversion funnel</h2>
<p>In devtools, conversion does not happen at signup. It happens when a developer gets something working.</p>
<p>A developer can create an account, read your docs, and still leave without ever using your product. What changes that is a working result. The moment they make a successful API call, see data returned, or complete a small task, that is when your product starts to make sense.</p>
<p>This is why tutorials matter more than most teams think. Documentation helps developers understand your product, but tutorials are what help them experience it. They are the bridge between interest and actual usage.</p>
<p>You can think of it simply. Documentation supports onboarding. Tutorials create the first success. That first success drives adoption.</p>
<p>If your tutorial does not get a developer to a working outcome quickly, your product never gets a fair chance.</p>
<h2>Where most developer tutorials break down</h2>
<p>Most developer tutorials look complete, but they fail where it matters. They do not help developers get to a working outcome fast enough. The result is content that seems useful but does not move the developer forward in the moment it counts.</p>
<p>Here are the most common points where developer tutorials break down:</p>
<ol>
<li><strong>No clear outcome</strong></li>
</ol>
<p>Many tutorials start with vague goals like “build something” or “get started.” The problem is that the developer does not know what success looks like.</p>
<p>Compare this:</p>
<ul>
<li>Build a sample app
vs</li>
<li>Create a working API request that returns user data</li>
</ul>
<p>The second gives a clear destination. The first leaves the developer guessing.</p>
<p>A good tutorial defines the exact outcome upfront. If a developer cannot picture what they will achieve, they are less likely to stay.</p>
<ol start="2">
<li><strong>Time to first success is too long</strong></li>
</ol>
<p>This is one of the biggest reasons tutorials fail.</p>
<p>Before a developer sees any result, they are asked to:</p>
<ul>
<li>install multiple dependencies</li>
<li>configure environments</li>
<li>understand several concepts at once</li>
</ul>
<p>By the time they reach anything meaningful, they are already fatigued.</p>
<p>This is where the idea of <a href="https://www.cloudblue.com/glossary/time-to-first-sale-ttfs/">Time to First Success (TTFS)</a> becomes important.</p>
<p>TTFS is how long it takes for a developer to get something working for the first time. The longer it takes to reach that first working result, the more likely the developer is to drop off. High-converting tutorials reduce this time aggressively.</p>
<ol start="3">
<li><strong>Tutorials explain instead of execute</strong></li>
</ol>
<p>Some tutorials read like documentation pages. They explain how the system works in detail, but delay actual execution.</p>
<p>Developers are not looking for a deep explanation at the start. They want to take action.</p>
<p>When tutorials lead with long explanations instead of a working step, they slow down progress. Explanation should support action, not replace it.</p>
<ol start="4">
<li><strong>Examples don’t actually work</strong></li>
</ol>
<p>Nothing breaks trust faster than a tutorial that does not produce the expected result.</p>
<p>Common issues include:</p>
<ul>
<li>missing steps or assumptions</li>
<li>outdated versions or dependencies</li>
<li>unclear configuration requirements</li>
</ul>
<p>If a developer follows your tutorial and it does not work, they will assume the product is unreliable.</p>
<p>This is where strong documentation practices matter. Examples should be complete, runnable, and validated, just like in high-quality <a href="https://reclear.io/blog/developer-documentation-that-gets-used">developer documentation that gets used</a>. The same principle applies even more strongly to tutorials.</p>
<ol start="5">
<li><strong>Not tied to a real use case</strong></li>
</ol>
<p>Some tutorials are technically correct but feel disconnected from real-world problems.</p>
<p>They demonstrate features in isolation without answering a practical question like:</p>
<ul>
<li>why would I use this</li>
<li>when would I need this</li>
</ul>
<p>Developers want to solve real problems. Tutorials that are not grounded in actual use cases feel disconnected from real-world problems and are easier to abandon.</p>
<p>Most developer tutorials fail not because they are wrong, but because they do not help developers move forward fast enough.</p>
<h2>The aha moment: developers don’t convert when they understand, they convert when they succeed</h2>
<p>It is easy to assume that developers convert when they understand your product. That&#x27;s if your tutorial explains things clearly enough, they will see the value and continue.</p>
<p>In reality, understanding is not what drives adoption. Execution is.</p>
<p>A developer can read your documentation, follow your architecture, and still leave without using your product. But the moment they get something working, even something small, the experience changes. The product stops being abstract and starts becoming useful.</p>
<p>This is the shift most teams miss.</p>
<p>A developer who understands your product might still leave.
A developer who gets something working will come back.</p>
<p>This is why tutorials matter so much. They are not just educational content. They are the first real interaction a developer has with your product.</p>
<p>If that interaction leads to success, you build confidence. If it leads to friction, you lose momentum.</p>
<p>Everything about your tutorial should be designed around this idea. Not how much you can explain, but how quickly you can help a developer succeed.</p>
<h2>How to fix your developer tutorials (practical system)</h2>
<p>Once you understand where developer tutorials fail and what actually drives conversion, the next step is fixing them. This is not about rewriting everything. It is about restructuring your tutorials around how developers make progress.</p>
<div><section><p>Start by being explicit about what the developer will achieve.</p><p>Avoid vague goals like:</p><ul>
<li>“Build a sample app”</li>
<li>“Get started with the API”</li>
</ul><p>Instead, define a clear, concrete result:</p><ul>
<li>“Create a user via the API and return a successful response”</li>
<li>“Upload a file and retrieve its URL”</li>
<li>“Authenticate a user and receive a valid token”</li>
</ul><aside><p>A strong developer tutorial answers this immediately: What will I have working at the end of this?</p></aside><p>You can make this even clearer by showing the expected output upfront.</p><pre><code class="language-json">{
  &quot;id&quot;: &quot;user_123&quot;,
  &quot;email&quot;: &quot;user@example.com&quot;
}
</code></pre></section><section><p>Most developer tutorials lose users before they even begin.</p><p>Too many tutorials start with:</p><ul>
<li>environment setup</li>
<li>dependency installation</li>
<li>configuration steps</li>
</ul><p>Before any value is shown.</p><p>Every extra step before the first success increases drop-off. Your goal is to reduce setup to only what is absolutely required. Instead of:</p><ul>
<li>installing multiple tools</li>
<li>configuring environments manually</li>
</ul><p>You should consider:</p><ul>
<li>providing a pre-configured example</li>
<li>using simple tools like curl or a single SDK</li>
<li>deferring advanced setup to later sections</li>
</ul><p>For example, instead of asking a developer to install a full SDK, start with:</p><pre><code class="language-bash">curl -X GET https://api.example.com/v1/users \
  -H &quot;Authorization: Bearer YOUR_API_KEY&quot;
</code></pre><p>This gets them to a working result faster. That is what matters.</p></section><section><p>Before writing explanations, make sure the tutorial works end-to-end.</p><p>This means:</p><ul>
<li>every step is tested</li>
<li>every command runs</li>
<li>every response is valid</li>
</ul><p>Do not write the tutorial as you build the product. Write it after you have a working flow.</p><aside><p>A simple rule: If you copy your own tutorial and it fails, the tutorial is not ready.</p></aside><p>For example, instead of:</p><pre><code class="language-bash">POST /v1/users
</code></pre><p>You should write the full, runnable version:</p><pre><code class="language-bash">curl -X POST https://api.example.com/v1/users \
  -H &quot;Authorization: Bearer sk_test_123456&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &#x27;{
    &quot;email&quot;: &quot;user@example.com&quot;
  }&#x27;
</code></pre><p>This reduces guesswork and builds trust immediately.</p></section><section><p>Once the working example is clear, add explanation that helps the developer move forward.</p><p>Focus on:</p><ul>
<li>what each step is doing</li>
<li>what can be changed</li>
<li>what to expect next</li>
</ul><p>Avoid long sections that explain the system before the developer has seen it work.</p><p>For example, instead of starting with: “Our authentication system uses token-based access with…”</p><p>You can start with:</p><pre><code class="language-bash">-H &quot;Authorization: Bearer YOUR_API_KEY&quot;
</code></pre><p><strong>Then explain:</strong> This header authenticates your request. Replace <code>YOUR_API_KEY</code> with your actual key.</p><p>Ideally, your explanation should support execution, not delay it.</p></section><section><p>This is where most teams underestimate the problem.</p><p>You need to measure how long it takes for a developer to get a working result.</p><p>Ask:</p><ul>
<li>How many steps before the first success</li>
<li>How long does it take to complete</li>
<li>Where do developers get stuck</li>
</ul><p>If it takes 15–20 minutes to get a result, your tutorial is already at risk.</p><p>High-converting developer tutorials aim for a working outcome within minutes, not tens of minutes</p><p>You can validate this by running the tutorial from scratch, asking someone unfamiliar to try it, and observing where they hesitate or drop off</p></section><section><p>Even after publishing, your tutorial is not finished.</p><p>Watch for:</p><ul>
<li>repeated developer questions</li>
<li>support tickets</li>
<li>points where users drop off</li>
</ul><p>These are signals of friction. Then iterate:</p><ul>
<li>simplify steps</li>
<li>remove unnecessary complexity</li>
<li>clarify instructions</li>
</ul><p>For example, If multiple developers struggle with authentication, your tutorial should be updated to make that step clearer or earlier.</p><p>This is the difference between tutorials that exist and tutorials that convert.</p></section></div>
<p>Fixing developer tutorials is not about adding more content. It is about making it easier for developers to succeed.</p>
<p>When your tutorials are built around clear outcomes, minimal setup, working examples, and fast results, they stop being just documentation. They become a reliable path to product adoption.</p>
<p>This is what separates tutorials that get read from tutorials that convert.</p>
<h2>Developer tutorials that actually convert</h2>
<p>Some devtools get this right. Not because they write more, but because they design tutorials around quick success.</p>
<p>If you look closely, the pattern is consistent. The best tutorials are not trying to explain everything. They are designed to get something working as fast as possible.</p>
<h3>Stripe: immediate API success</h3>
<p>Stripe’s tutorials are built around one simple idea. Make a working API request as quickly as possible.</p>
<p>You are not asked to understand the entire system first. You are given a clear task, a ready-to-use request, and an expected response.</p>
<p>Within minutes, you can:</p>
<ul>
<li>send a request</li>
<li>receive a response</li>
<li>confirm that everything works</li>
</ul>
<p>That first success builds confidence. From there, developers are more willing to explore deeper parts of the product.</p>
<h3>Supabase: build something real, fast</h3>
<p>Supabase tutorials focus on building something tangible early.</p>
<p>Instead of abstract examples, you are guided to:</p>
<ul>
<li>create a project</li>
<li>connect to a database</li>
<li>fetch or insert data</li>
</ul>
<p>You are not left guessing what the end result should be. You see it happen.</p>
<p>This makes the tutorial feel useful immediately. It is not just showing features, it is helping you achieve something that resembles a real use case.</p>
<h3>Vercel: deploy and see it live</h3>
<p>Vercel tutorials reduce friction to the point where deployment feels instant.</p>
<p>You go from:</p>
<ul>
<li>connecting a repository
to</li>
<li>having a live URL</li>
</ul>
<p>In just a few steps.</p>
<p>There is almost no delay between action and result. That speed is what makes the experience memorable.</p>
<p>The tutorial does not try to teach everything about deployment. It focuses on getting something live first. Everything else can come later.</p>
<p>Across these examples, the same principles show up:</p>
<ul>
<li>you get a working result quickly</li>
<li>the steps are clear and minimal</li>
<li>the outcome is visible and verifiable</li>
<li>the tutorial is tied to something real</li>
</ul>
<p>This is what high-converting developer tutorials look like.</p>
<p>They are not longer. They are not more detailed. They are simply better designed around how developers experience progress.</p>
<h2>Conclusion</h2>
<p>Developer tutorials are not just content. They are where adoption happens. When a tutorial leads to a quick, working result, it builds confidence and keeps developers moving forward.</p>
<p>The difference is not in how much you explain, but how quickly you help a developer succeed.</p>
<p>At <a href="https://reclear.io">Reclear</a>, we help teams design and write developer tutorials and documentation that drive real product adoption. If you are looking to improve your tutorials or build them from scratch, you can <a href="https://reclear.io/contact">reach out</a> to see how we can help.</p>
<p><a href="https://reclear.io/book-a-call">Work with us</a>: Book a call to discuss how we can help with your developer documentation and technical content.</p>]]></content:encoded>
      <pubDate>Sun, 15 Mar 2026 00:00:00 GMT</pubDate>
      <author>hello@reclear.io (Olusegun Durojaye)</author>
      <category>Tutorials</category>
      <category>DeveloperExperience</category>
      <category>Conversion</category>
      <enclosure url="https://reclear.io/blog/why-developer-tutorials-arent-converting/cover/cover.webp" type="image/webp" length="0" />
    </item>
  </channel>
</rss>