<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="rss.xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>DevOps Journey Blog</title>
        <link>https://forklore.dev/blog</link>
        <description>DevOps Journey Blog</description>
        <lastBuildDate>Mon, 15 Jun 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>en</language>
        <item>
            <title><![CDATA[When the Pod Is Fine but Nothing Works — Debugging Kubernetes Services and PVCs]]></title>
            <link>https://forklore.dev/blog/k8s-service-and-pvc-debugging</link>
            <guid>https://forklore.dev/blog/k8s-service-and-pvc-debugging</guid>
            <pubDate>Mon, 15 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A debugging workflow for Kubernetes problems that aren't the Pod's fault: Service connectivity (endpoints empty vs connection refused vs timeout) and PVCs stuck in Pending.]]></description>
            <content:encoded><![CDATA[<p>Last time I deliberately broke Pods three ways and watched them fail to start <br>
<!-- -->(<a class="" href="https://forklore.dev/blog/k8s-pod-crashloopbackoff">CrashLoopBackOff and friends</a>). <br>
<!-- -->This time the Pods are all <strong>Running and healthy</strong> — and things still don't work. <br>
<!-- -->The bug isn't in the Pod; it's in the plumbing around it: the <strong>Service → Endpoints → Pod</strong> <br>
<!-- -->forwarding chain, and the <strong>PVC → StorageClass → PV</strong> binding chain. <br>
<!-- -->The skill here is knowing <em>which link</em> broke without guessing.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="part-1-service-connectivity--get-endpoints-splits-the-problem-in-two">Part 1: Service connectivity — <code>get endpoints</code> splits the problem in two<a href="https://forklore.dev/blog/k8s-service-and-pvc-debugging#part-1-service-connectivity--get-endpoints-splits-the-problem-in-two" class="hash-link" aria-label="Direct link to part-1-service-connectivity--get-endpoints-splits-the-problem-in-two" title="Direct link to part-1-service-connectivity--get-endpoints-splits-the-problem-in-two" translate="no">​</a></h2>
<p>When you can't reach a Service, the single most useful first command is <strong>not</strong> <code>describe pod</code>.
It's:</p>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl get endpoints &lt;svc&gt;</span><br></div></code></pre></div></div>
<p>Endpoints is the table that says "which Pod IPs does this Service actually forward to."
That one command cleaves every connectivity bug into two halves: <strong>can't find the Pods</strong>
(endpoints empty) vs <strong>found them but can't reach them</strong> (endpoints populated). Each half
points somewhere completely different.</p>
<h3 class="anchor anchorTargetStickyNavbar_jZnc" id="failure-1-selector-doesnt-match-the-pod-labels--endpoints-empty">Failure 1: selector doesn't match the Pod labels → endpoints empty<a href="https://forklore.dev/blog/k8s-service-and-pvc-debugging#failure-1-selector-doesnt-match-the-pod-labels--endpoints-empty" class="hash-link" aria-label="Direct link to Failure 1: selector doesn't match the Pod labels → endpoints empty" title="Direct link to Failure 1: selector doesn't match the Pod labels → endpoints empty" translate="no">​</a></h3>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl expose deployment app --port=80 --target-port=80 --name=app-bad-selector</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl patch svc app-bad-selector -p '{"spec":{"selector":{"app":"WRONG"}}}'</span><br></div></code></pre></div></div>
<ul>
<li class="">Symptom: can't connect; the Pods are all Running.</li>
<li class="">Investigation: <code>kubectl get endpoints app-bad-selector</code> → <strong>empty (<code>&lt;none&gt;</code>)</strong>. Then <code>kubectl get svc app-bad-selector -o wide</code> to read the Selector, and compare against <code>kubectl get pods --show-labels</code>.</li>
<li class="">Cause: the Service selector matches no Pod labels, so the Endpoints controller can't assemble a backend list.</li>
<li class="">Fix: correct the selector (or the Pod labels) so they line up.</li>
<li class="">Concept: a Service doesn't know about Pods directly — it only knows <strong>labels</strong>. The "which Pod IPs" lookup lives in the Endpoints / EndpointSlice object. Wrong selector → that table is empty. <strong>Empty endpoints almost always means a selector problem.</strong></li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_jZnc" id="failure-2-targetport-points-at-a-port-nothing-is-listening-on--connection-refused">Failure 2: targetPort points at a port nothing is listening on → connection refused<a href="https://forklore.dev/blog/k8s-service-and-pvc-debugging#failure-2-targetport-points-at-a-port-nothing-is-listening-on--connection-refused" class="hash-link" aria-label="Direct link to Failure 2: targetPort points at a port nothing is listening on → connection refused" title="Direct link to Failure 2: targetPort points at a port nothing is listening on → connection refused" translate="no">​</a></h3>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl expose deployment app --port=80 --target-port=8080 --name=app-bad-port</span><br></div></code></pre></div></div>
<ul>
<li class="">Symptom: still can't connect — but it <em>breaks differently</em> than Failure 1.</li>
<li class="">Investigation: <code>kubectl get endpoints app-bad-port</code> → this time there <strong>are</strong> IPs (the selector matches, so endpoints list <code>podIP:8080</code>). Then curl it and watch <em>how</em> it fails → <strong>connection refused</strong>, immediately, not a hang.</li>
<li class="">Cause: the Service port is fine, but <code>targetPort</code> is 8080 while the container actually listens on 80. kube-proxy DNATs the packet to <code>podIP:8080</code>, nothing is listening there, and the kernel replies with a TCP RST.</li>
<li class="">Fix: set <code>targetPort</code> to the port the container really listens on.</li>
<li class="">Concept: <strong>endpoints populated + connection refused → it's a targetPort / wrong-container-port problem.</strong> "Refused" means the packet reached the Pod and got rejected — so it's not a routing problem, it's knocking on the wrong door.</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_jZnc" id="failure-3-networkpolicy-silently-drops-the-packet--timeout">Failure 3: NetworkPolicy silently drops the packet → timeout<a href="https://forklore.dev/blog/k8s-service-and-pvc-debugging#failure-3-networkpolicy-silently-drops-the-packet--timeout" class="hash-link" aria-label="Direct link to Failure 3: NetworkPolicy silently drops the packet → timeout" title="Direct link to Failure 3: NetworkPolicy silently drops the packet → timeout" translate="no">​</a></h3>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl get netpol -A</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl describe netpol &lt;name&gt; -n &lt;ns&gt;</span><br></div></code></pre></div></div>
<ul>
<li class="">Symptom: endpoints are populated, targetPort is correct, and curl <strong>times out</strong> (hangs with no response) — <em>not</em> "refused".</li>
<li class="">Investigation: <code>kubectl get netpol -A</code> to see if any policy selects the source or destination Pod; <code>describe</code> to read its <code>PodSelector</code> / <code>Ingress</code> / <code>Egress</code>.</li>
<li class="">Cause: a NetworkPolicy is dropping this traffic. Dropped packets get no reply (no RST), which is exactly why you see a timeout instead of a refusal.</li>
<li class="">Fix: allow the source→destination flow in the relevant policy, or adjust its PodSelector.</li>
</ul>
<p>Three things about NetworkPolicy that trip people up:</p>
<ol>
<li class=""><strong>Whitelist flip.</strong> A Pod with no policy selecting it is "allow all." The moment <em>any</em> policy selects it — even one that only lists Ingress — that direction flips to "deny all except what's explicitly allowed." A common gotcha is a <code>podSelector: {}</code> default-deny someone added.</li>
<li class=""><strong>Two directions.</strong> Either the destination Pod's Ingress doesn't allow the source, or the source Pod's Egress doesn't allow the destination. Either one blocks the flow, so check policies in <em>both</em> namespaces.</li>
<li class=""><strong>The CNI has to enforce it.</strong> This is the big one for local labs:</li>
</ol>
<div class="theme-admonition theme-admonition-warning admonition_HE9s alert alert--warning"><div class="admonitionHeading_EnnA"><span class="admonitionIcon_d9Hy"><svg viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8.893 1.5c-.183-.31-.52-.5-.887-.5s-.703.19-.886.5L.138 13.499a.98.98 0 0 0 0 1.001c.193.31.53.501.886.501h13.964c.367 0 .704-.19.877-.5a1.03 1.03 0 0 0 .01-1.002L8.893 1.5zm.133 11.497H6.987v-2.003h2.039v2.003zm0-3.004H6.987V5.987h2.039v4.006z"></path></svg></span>warning</div><div class="admonitionContent_FwsI"><p><strong>kind's default CNI (kindnet) does not enforce NetworkPolicy.</strong> You can <code>kubectl apply</code> a policy, see it in <code>kubectl get netpol</code>, and traffic still flows — because nothing is enforcing it. To actually reproduce Failure 3 you need a CNI that enforces policy, like Calico or Cilium. I verified this by spinning up a separate kind cluster with <code>disableDefaultCNI: true</code> and installing Calico: the <em>same</em> default-deny policy that did nothing under kindnet turned curl into a clean timeout under Calico. Same YAML, different behavior — because the difference was never the policy, it was the CNI.</p></div></div>
<h3 class="anchor anchorTargetStickyNavbar_jZnc" id="service-decision-tree">Service decision tree<a href="https://forklore.dev/blog/k8s-service-and-pvc-debugging#service-decision-tree" class="hash-link" aria-label="Direct link to Service decision tree" title="Direct link to Service decision tree" translate="no">​</a></h3>
<div class="language-text codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-text codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Can't reach a Service → ① kubectl get endpoints &lt;svc&gt;</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">├─ endpoints empty ─────────────► selector doesn't match labels → fix selector</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">└─ endpoints populated → curl and watch HOW it fails</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     ├─ connection refused (instant) ─► wrong targetPort / nothing listening</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">     └─ timeout (hangs) ─────────────► suspect NetworkPolicy dropping packets</span><br></div></code></pre></div></div>
<p>The cheapest signal in the whole tree is <strong>refused vs timeout</strong>: refused means the packet
arrived and was rejected; timeout means it was swallowed on the way.</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="part-2-pvc-stuck-in-pending--read-the-events-not-the-logs">Part 2: PVC stuck in Pending — read the Events, not the logs<a href="https://forklore.dev/blog/k8s-service-and-pvc-debugging#part-2-pvc-stuck-in-pending--read-the-events-not-the-logs" class="hash-link" aria-label="Direct link to Part 2: PVC stuck in Pending — read the Events, not the logs" title="Direct link to Part 2: PVC stuck in Pending — read the Events, not the logs" translate="no">​</a></h2>
<p>A PVC that never leaves <code>Pending</code> drags its Pod down with it (the Pod stays Pending too,
it can't even schedule). There are no logs to read here — the answer is in
<code>kubectl describe pvc</code>.</p>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl apply -f - &lt;&lt;'EOF'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">apiVersion: v1</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">kind: PersistentVolumeClaim</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">metadata:</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  name: stuck-pvc</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">spec:</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  accessModes: ["ReadWriteOnce"]</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  storageClassName: nonexistent-sc</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  resources:</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    requests:</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      storage: 1Gi</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">EOF</span><br></div></code></pre></div></div>
<ul>
<li class="">Symptom: <code>kubectl get pvc stuck-pvc</code> sits at <code>Pending</code>, never <code>Bound</code>.</li>
<li class="">Investigation: <code>kubectl describe pvc stuck-pvc</code> (read the Events), then <code>kubectl get storageclass</code> to see what actually exists.</li>
<li class="">Cause (this example): the requested <code>storageClassName: nonexistent-sc</code> doesn't exist, so no provisioner claims the PVC and it binds to nothing.</li>
<li class="">Fix: use a StorageClass that exists (<code>kubectl get sc</code> — there's usually one marked <code>(default)</code>), or omit <code>storageClassName</code> to take the default.</li>
</ul>
<p>The root causes worth memorizing as a checklist:</p>
<ul>
<li class=""><strong>StorageClass doesn't exist</strong> — Events say the class can't be found (this example). Fix the name.</li>
<li class=""><strong>Static provisioning, no matching PV</strong> — with no dynamic provisioner you must pre-create a PV; if none has capacity ≥ the request with a matching accessMode and storageClassName, the PVC stays Pending. Create a matching PV, or switch to a class that supports dynamic provisioning.</li>
<li class=""><strong><code>volumeBindingMode: WaitForFirstConsumer</code></strong> — this Pending is <em>normal</em>. The class deliberately waits until a Pod actually uses the PVC before binding, so the volume lands in the right node/zone. Not an error; create a Pod that mounts it and binding happens.</li>
</ul>
<div class="theme-admonition theme-admonition-note admonition_HE9s alert alert--secondary"><div class="admonitionHeading_EnnA"><span class="admonitionIcon_d9Hy"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>note</div><div class="admonitionContent_FwsI"><p>PVC Pending and Pod Pending look similar but are diagnosed completely differently.
Pod Pending is a <em>scheduling</em> failure (insufficient resources, taints, nodeSelector) — you
look at <code>kubectl describe pod</code> Events and the scheduler. PVC Pending is a <em>binding</em>
failure — you look at <code>kubectl describe pvc</code> and <code>kubectl get sc</code>. Don't conflate them.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="the-one-table-to-keep">The one table to keep<a href="https://forklore.dev/blog/k8s-service-and-pvc-debugging#the-one-table-to-keep" class="hash-link" aria-label="Direct link to The one table to keep" title="Direct link to The one table to keep" translate="no">​</a></h2>
<table><thead><tr><th>Symptom</th><th>First command</th><th>What to read</th><th>Likely cause</th></tr></thead><tbody><tr><td>Service: endpoints empty</td><td><code>kubectl get endpoints &lt;svc&gt;</code></td><td><code>&lt;none&gt;</code></td><td>selector ≠ Pod labels</td></tr><tr><td>Service: refused (instant)</td><td>curl + <code>kubectl get endpoints</code></td><td>populated, but RST</td><td>wrong targetPort / nothing listening</td></tr><tr><td>Service: timeout (hangs)</td><td><code>kubectl get netpol -A</code></td><td>matching PodSelector</td><td>NetworkPolicy dropping packets</td></tr><tr><td>PVC stuck Pending</td><td><code>kubectl describe pvc &lt;pvc&gt;</code></td><td>Events + <code>get sc</code></td><td>no/wrong StorageClass, no matching PV, or WaitForFirstConsumer</td></tr></tbody></table>
<p>The thread running through both halves: when a Pod is healthy but unreachable or unschedulable,
stop staring at the Pod. Walk the chain it depends on — Endpoints for networking, StorageClass
and PV for storage — and let <em>how it fails</em> (empty vs refused vs timeout vs Pending) tell you
which link to fix.</p>]]></content:encoded>
            <category>kubernetes</category>
            <category>devops</category>
        </item>
        <item>
            <title><![CDATA[Live "online users" on a static blog: GA4 Realtime + a Cloudflare Worker]]></title>
            <link>https://forklore.dev/blog/ga4-realtime-online-on-a-static-site</link>
            <guid>https://forklore.dev/blog/ga4-realtime-online-on-a-static-site</guid>
            <pubDate>Sun, 14 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Show live online users on a static site: a Cloudflare Worker signs a Google token to proxy the GA4 Realtime API — build-time vs runtime, plus 3 traps.]]></description>
            <content:encoded><![CDATA[<p>I wanted the homepage stats bar to show a <strong>current online users</strong> number, next to the
build-time stats (posts, tags, days since last post). It sounds like a one-liner, but a
static site fundamentally <strong>cannot</strong> call the GA4 Realtime API directly — the API needs a
service-account private key, and anything you ship to the browser is public. This post is
the whole path I took: the build-time-vs-runtime split, why a proxy is mandatory, the
Cloudflare Worker that signs its own Google token, wiring the frontend, and the three traps
I fell into along the way.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="the-realization-build-time-vs-runtime">The realization: build-time vs runtime<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#the-realization-build-time-vs-runtime" class="hash-link" aria-label="Direct link to The realization: build-time vs runtime" title="Direct link to The realization: build-time vs runtime" translate="no">​</a></h2>
<p>The stats bar already had four numbers, and they were all computed <strong>at build time</strong> by
reading the repo:</p>
<ul>
<li class=""><code>posts</code> / <code>tags</code> — counted from blog frontmatter while Docusaurus builds.</li>
<li class=""><code>last post</code> — diff between today and the newest post date.</li>
</ul>
<p>Those are static: bake them into the HTML and they're correct until the next build. A
<strong>visitor count is the opposite</strong> — it only exists at runtime, on a server that's recording
traffic. The site itself records nothing. So any "visitors" or "online" number needs an
<strong>external analytics source</strong> queried from the browser. That single distinction drove the
whole design.</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="why-you-cant-call-ga-realtime-from-the-browser">Why you can't call GA Realtime from the browser<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#why-you-cant-call-ga-realtime-from-the-browser" class="hash-link" aria-label="Direct link to Why you can't call GA Realtime from the browser" title="Direct link to Why you can't call GA Realtime from the browser" translate="no">​</a></h2>
<p>GA4's realtime data comes from the Analytics Data API (<code>properties/{id}:runRealtimeReport</code>,
metric <code>activeUsers</code>). To call it you must authenticate as a <strong>service account</strong> — which
means holding its <strong>private key</strong> and signing a JWT to exchange for an access token.</p>
<p>A static site's JavaScript is fully visible to anyone. Put that key in the bundle and you've
handed out read access to your analytics. So the only safe shape is a tiny server in the
middle:</p>
<div class="language-text codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-text codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">browser (stats bar)  ──►  Cloudflare Worker  ──►  GA4 Realtime API</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                          (holds the key,</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                           caches ~60s,</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">                           returns { "active": N })</span><br></div></code></pre></div></div>
<p>The Worker is the only thing that ever sees the key. The browser only ever talks to the
Worker, which returns a harmless <code>{ "active": N }</code> with permissive CORS.</p>
<div class="theme-admonition theme-admonition-note admonition_HE9s alert alert--secondary"><div class="admonitionHeading_EnnA"><span class="admonitionIcon_d9Hy"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>note</div><div class="admonitionContent_FwsI"><p>GA's "active users" means <strong>users active in the last 30 minutes</strong> — the exact number GA's
Realtime report shows. It's the industry-standard "online now," not a literal "looking at
the page this very second."</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="step-1--page-tracking-the-easy-seo-part">Step 1 — page tracking (the easy, SEO part)<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#step-1--page-tracking-the-easy-seo-part" class="hash-link" aria-label="Direct link to Step 1 — page tracking (the easy, SEO part)" title="Direct link to Step 1 — page tracking (the easy, SEO part)" translate="no">​</a></h2>
<p>This is the part most people mean by "add Google Analytics," and it's trivial. Docusaurus'
classic preset has a built-in gtag plugin that also handles client-side route changes:</p>
<div class="language-ts codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-ts codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token comment" style="color:rgb(98, 114, 164)">// docusaurus.config.ts</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> </span><span class="token constant" style="color:rgb(189, 147, 249)">GA_MEASUREMENT_ID</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'G-XXXXXXXXXX'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">presets</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token string" style="color:rgb(255, 121, 198)">'classic'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token comment" style="color:rgb(98, 114, 164)">// ...</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token operator">...</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token constant" style="color:rgb(189, 147, 249)">GA_MEASUREMENT_ID</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token operator">?</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain">gtag</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain">trackingID</span><span class="token operator">:</span><span class="token plain"> </span><span class="token constant" style="color:rgb(189, 147, 249)">GA_MEASUREMENT_ID</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> anonymizeIP</span><span class="token operator">:</span><span class="token plain"> </span><span class="token boolean">true</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><br></div></code></pre></div></div>
<p>This alone gives you SEO/traffic analytics. It does <strong>not</strong> give you the realtime number —
that's Steps 2–4.</p>
<div class="theme-admonition theme-admonition-note admonition_HE9s alert alert--secondary"><div class="admonitionHeading_EnnA"><span class="admonitionIcon_d9Hy"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>note</div><div class="admonitionContent_FwsI"><p>GA4 shows you three different IDs and it's easy to grab the wrong one:</p><ul>
<li class=""><strong>Measurement ID</strong> (<code>G-XXXXXXXXXX</code>) — for gtag page tracking (this step).</li>
<li class=""><strong>Stream ID</strong> (a long number on the data-stream page) — <em>not</em> what the API wants.</li>
<li class=""><strong>Property ID</strong> (a number under Admin → Property settings) — this is what the Realtime API
call below needs.</li>
</ul></div></div>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="step-2--service-account--data-api">Step 2 — service account + Data API<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#step-2--service-account--data-api" class="hash-link" aria-label="Direct link to Step 2 — service account + Data API" title="Direct link to Step 2 — service account + Data API" translate="no">​</a></h2>
<ol>
<li class="">In Google Cloud Console, enable the <strong>Google Analytics Data API</strong>.</li>
<li class="">Create a <strong>service account</strong>, then create and download a <strong>JSON key</strong>.</li>
<li class="">In <strong>GA Admin → Property access management</strong>, add the service account's <code>client_email</code>
as a <strong>Viewer</strong> on the property. (Skipping this is Pitfall 3 below.)</li>
</ol>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="step-3--the-worker-signing-a-google-jwt-with-webcrypto">Step 3 — the Worker: signing a Google JWT with WebCrypto<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#step-3--the-worker-signing-a-google-jwt-with-webcrypto" class="hash-link" aria-label="Direct link to Step 3 — the Worker: signing a Google JWT with WebCrypto" title="Direct link to Step 3 — the Worker: signing a Google JWT with WebCrypto" translate="no">​</a></h2>
<p>Cloudflare Workers have no <code>googleapis</code> SDK, but they do have WebCrypto, so you build and
sign the JWT yourself. The flow: make a JWT claiming the service-account identity, sign it
RS256 with the private key, exchange it for an access token, then call the Realtime report.
The interesting bits:</p>
<div class="language-js codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-js codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">async</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">function</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">getAccessToken</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token parameter">env</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> now </span><span class="token operator">=</span><span class="token plain"> </span><span class="token known-class-name class-name">Math</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">floor</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token known-class-name class-name">Date</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">now</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token operator">/</span><span class="token plain"> </span><span class="token number">1000</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> header </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token literal-property property">alg</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'RS256'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token literal-property property">typ</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'JWT'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> claim </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token literal-property property">iss</span><span class="token operator">:</span><span class="token plain"> env</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token constant" style="color:rgb(189, 147, 249)">GA_CLIENT_EMAIL</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token literal-property property">scope</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'https://www.googleapis.com/auth/analytics.readonly'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token literal-property property">aud</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'https://oauth2.googleapis.com/token'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token literal-property property">iat</span><span class="token operator">:</span><span class="token plain"> now</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token literal-property property">exp</span><span class="token operator">:</span><span class="token plain"> now </span><span class="token operator">+</span><span class="token plain"> </span><span class="token number">3600</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> unsigned </span><span class="token operator">=</span><span class="token plain"> </span><span class="token template-string template-punctuation string" style="color:rgb(255, 121, 198)">`</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token template-string interpolation function" style="color:rgb(80, 250, 123)">b64url</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token template-string interpolation known-class-name class-name">JSON</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token template-string interpolation method function property-access" style="color:rgb(80, 250, 123)">stringify</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token template-string interpolation">header</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token template-string string" style="color:rgb(255, 121, 198)">.</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token template-string interpolation function" style="color:rgb(80, 250, 123)">b64url</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token template-string interpolation known-class-name class-name">JSON</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token template-string interpolation method function property-access" style="color:rgb(80, 250, 123)">stringify</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token template-string interpolation">claim</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token template-string template-punctuation string" style="color:rgb(255, 121, 198)">`</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> key </span><span class="token operator">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:rgb(189, 147, 249);font-style:italic">await</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">importPrivateKey</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">env</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token constant" style="color:rgb(189, 147, 249)">GA_PRIVATE_KEY</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"> </span><span class="token comment" style="color:rgb(98, 114, 164)">// PKCS8 PEM -&gt; CryptoKey</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> sig </span><span class="token operator">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:rgb(189, 147, 249);font-style:italic">await</span><span class="token plain"> crypto</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token property-access">subtle</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">sign</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token literal-property property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'RSASSA-PKCS1-v1_5'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> key</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">new</span><span class="token plain"> </span><span class="token class-name">TextEncoder</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">encode</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">unsigned</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> jwt </span><span class="token operator">=</span><span class="token plain"> </span><span class="token template-string template-punctuation string" style="color:rgb(255, 121, 198)">`</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token template-string interpolation">unsigned</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token template-string string" style="color:rgb(255, 121, 198)">.</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token template-string interpolation function" style="color:rgb(80, 250, 123)">b64urlBytes</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token template-string interpolation keyword" style="color:rgb(189, 147, 249);font-style:italic">new</span><span class="token template-string interpolation"> </span><span class="token template-string interpolation class-name">Uint8Array</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token template-string interpolation">sig</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token template-string template-punctuation string" style="color:rgb(255, 121, 198)">`</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> res </span><span class="token operator">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:rgb(189, 147, 249);font-style:italic">await</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">fetch</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">'https://oauth2.googleapis.com/token'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token literal-property property">method</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'POST'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token literal-property property">headers</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token string-property property">'content-type'</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'application/x-www-form-urlencoded'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token literal-property property">body</span><span class="token operator">:</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">new</span><span class="token plain"> </span><span class="token class-name">URLSearchParams</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token literal-property property">grant_type</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'urn:ietf:params:oauth:grant-type:jwt-bearer'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token literal-property property">assertion</span><span class="token operator">:</span><span class="token plain"> jwt</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword control-flow" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token keyword control-flow" style="color:rgb(189, 147, 249);font-style:italic">await</span><span class="token plain"> res</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">json</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token property-access">access_token</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>Two details that bite:</p>
<ul>
<li class="">The private key from the JSON is <strong>PKCS8 PEM</strong>. Strip the <code>-----BEGIN/END-----</code> lines and
whitespace, base64-decode to bytes, then <code>crypto.subtle.importKey('pkcs8', ...)</code>. Also
normalize <code>\n</code> → real newlines, because secrets are often stored with escaped newlines.</li>
<li class=""><strong>Cache both</strong> the access token (~55 min) and the active-users result (~60 s) in
module scope. Otherwise every page load hits Google and you'll burn through quota.</li>
</ul>
<p>The actual report call is small:</p>
<div class="language-js codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-js codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> res </span><span class="token operator">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:rgb(189, 147, 249);font-style:italic">await</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">fetch</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token template-string template-punctuation string" style="color:rgb(255, 121, 198)">`</span><span class="token template-string string" style="color:rgb(255, 121, 198)">https://analyticsdata.googleapis.com/v1beta/properties/</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token template-string interpolation">env</span><span class="token template-string interpolation punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token template-string interpolation constant" style="color:rgb(189, 147, 249)">GA_PROPERTY_ID</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token template-string string" style="color:rgb(255, 121, 198)">:runRealtimeReport</span><span class="token template-string template-punctuation string" style="color:rgb(255, 121, 198)">`</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token literal-property property">method</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'POST'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">   </span><span class="token literal-property property">headers</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token literal-property property">authorization</span><span class="token operator">:</span><span class="token plain"> </span><span class="token template-string template-punctuation string" style="color:rgb(255, 121, 198)">`</span><span class="token template-string string" style="color:rgb(255, 121, 198)">Bearer </span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(248, 248, 242)">${</span><span class="token template-string interpolation">token</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token template-string template-punctuation string" style="color:rgb(255, 121, 198)">`</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string-property property">'content-type'</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'application/json'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">   </span><span class="token literal-property property">body</span><span class="token operator">:</span><span class="token plain"> </span><span class="token known-class-name class-name">JSON</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">stringify</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token literal-property property">metrics</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token literal-property property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'activeUsers'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> data </span><span class="token operator">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:rgb(189, 147, 249);font-style:italic">await</span><span class="token plain"> res</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">json</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> active </span><span class="token operator">=</span><span class="token plain"> </span><span class="token known-class-name class-name">Number</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">data</span><span class="token operator">?.</span><span class="token plain">rows</span><span class="token operator">?.</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token number">0</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token operator">?.</span><span class="token plain">metricValues</span><span class="token operator">?.</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token number">0</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token operator">?.</span><span class="token plain">value </span><span class="token operator">??</span><span class="token plain"> </span><span class="token number">0</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></div></code></pre></div></div>
<p>Secrets are set with Wrangler (note the names must match <code>env.X</code> in the code):</p>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">npx wrangler secret put GA_CLIENT_EMAIL   # paste the client_email</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">npx wrangler secret put GA_PRIVATE_KEY    # paste the private_key value</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">npx wrangler deploy</span><br></div></code></pre></div></div>
<p><code>GA_PROPERTY_ID</code> and <code>ALLOWED_ORIGIN</code> are non-secret, so they live in <code>wrangler.toml</code>.</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="step-4--wire-the-frontend-poll-every-60s">Step 4 — wire the frontend (poll every 60s)<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#step-4--wire-the-frontend-poll-every-60s" class="hash-link" aria-label="Direct link to Step 4 — wire the frontend (poll every 60s)" title="Direct link to Step 4 — wire the frontend (poll every 60s)" translate="no">​</a></h2>
<p>The homepage reads the Worker URL from config and polls it after hydration. During the
static build there's no fetch, so it renders a placeholder, then fills in on the client:</p>
<div class="language-tsx codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-tsx codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">function</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">useOnline</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">apiUrl</span><span class="token operator">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">string</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token operator">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(189, 147, 249)">string</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain">value</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> setValue</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">useState</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">'···'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token function" style="color:rgb(80, 250, 123)">useEffect</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token arrow operator">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token operator">!</span><span class="token plain">apiUrl</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">setValue</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">'—'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">let</span><span class="token plain"> alive </span><span class="token operator">=</span><span class="token plain"> </span><span class="token boolean">true</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> </span><span class="token function-variable function" style="color:rgb(80, 250, 123)">load</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token arrow operator">=&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">      </span><span class="token function" style="color:rgb(80, 250, 123)">fetch</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">apiUrl</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">then</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">r</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token arrow operator">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">r</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token property-access">ok</span><span class="token plain"> </span><span class="token operator">?</span><span class="token plain"> r</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">json</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token operator">:</span><span class="token plain"> </span><span class="token known-class-name class-name">Promise</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">reject</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">then</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">d</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token arrow operator">=&gt;</span><span class="token plain"> alive </span><span class="token operator">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">setValue</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token known-class-name class-name">String</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">d</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token property-access">active</span><span class="token plain"> </span><span class="token operator">??</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'—'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">        </span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token method function property-access" style="color:rgb(80, 250, 123)">catch</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token arrow operator">=&gt;</span><span class="token plain"> alive </span><span class="token operator">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">setValue</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token string" style="color:rgb(255, 121, 198)">'—'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token function" style="color:rgb(80, 250, 123)">load</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> id </span><span class="token operator">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">setInterval</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">load</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token number">60_000</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token arrow operator">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> alive </span><span class="token operator">=</span><span class="token plain"> </span><span class="token boolean">false</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">clearInterval</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">id</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token plain">apiUrl</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">return</span><span class="token plain"> value</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>A failed/unconfigured fetch falls back to <code>—</code>, so the page never shows a broken stat.</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="step-5--serve-it-from-your-own-domain-optional-but-cleaner">Step 5 — serve it from your own domain (optional, but cleaner)<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#step-5--serve-it-from-your-own-domain-optional-but-cleaner" class="hash-link" aria-label="Direct link to Step 5 — serve it from your own domain (optional, but cleaner)" title="Direct link to Step 5 — serve it from your own domain (optional, but cleaner)" translate="no">​</a></h2>
<p>The Worker deploys to a <code>*.workers.dev</code> URL by default. If your domain's DNS is already
managed by Cloudflare, you can serve it from your own subdomain instead — nicer to read,
not tied to the workers.dev subdomain, and less likely to be tripped up by privacy
extensions or networks that block <code>*.workers.dev</code>.</p>
<p>Add a <strong>custom-domain route</strong> to <code>wrangler.toml</code> and redeploy:</p>
<div class="language-toml codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-toml codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">[[routes]]</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">pattern = "ga4-proxy.forklore.dev"</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">custom_domain = true</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">npx wrangler deploy</span><br></div></code></pre></div></div>
<p><code>wrangler deploy</code> provisions the DNS record <strong>and</strong> the TLS certificate automatically (the
cert takes a minute or two). Then point the frontend at the new URL:</p>
<div class="language-ts codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-ts codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> </span><span class="token constant" style="color:rgb(189, 147, 249)">ONLINE_API_URL</span><span class="token plain"> </span><span class="token operator">=</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'https://ga4-proxy.forklore.dev'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></div></code></pre></div></div>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">curl https://ga4-proxy.forklore.dev   # {"active":0}</span><br></div></code></pre></div></div>
<div class="theme-admonition theme-admonition-note admonition_HE9s alert alert--secondary"><div class="admonitionHeading_EnnA"><span class="admonitionIcon_d9Hy"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>note</div><div class="admonitionContent_FwsI"><p><code>ALLOWED_ORIGIN</code> does <strong>not</strong> change — it stays <code>https://forklore.dev</code>. The Worker's <em>own</em>
hostname (where it's served) and the <em>caller's</em> origin (who's allowed to call it, the CORS
setting) are two different things. A custom domain only needs the <strong>zone</strong> to be on
Cloudflare; it works even if the blog itself is hosted elsewhere.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="folding-in-the-visitor-count-and-dropping-goatcounter">Folding in the visitor count (and dropping GoatCounter)<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#folding-in-the-visitor-count-and-dropping-goatcounter" class="hash-link" aria-label="Direct link to Folding in the visitor count (and dropping GoatCounter)" title="Direct link to Folding in the visitor count (and dropping GoatCounter)" translate="no">​</a></h2>
<p>The realtime "online" number is only half the stats bar — I also wanted an all-time
<strong>unique visitors</strong> count. My first version used <a href="https://www.goatcounter.com/" target="_blank" rel="noopener noreferrer" class="">GoatCounter</a>:
cookieless, zero-config, a one-line script plus a public <code>/counter/TOTAL.json</code> endpoint the
page could fetch directly — no proxy needed. It worked.</p>
<p>Then I deleted it. The reasoning:</p>
<ul>
<li class="">GA was <strong>already in the page</strong> for SEO/analytics, and the Worker <strong>already existed</strong> for the
realtime number. GoatCounter's headline advantage is being <strong>cookieless / privacy-first</strong> —
but that edge evaporates the moment you're already running GA. I wasn't actually buying the
one thing GoatCounter is best at.</li>
<li class="">Keeping it meant <strong>two analytics sources to reconcile</strong> and a <strong>second third-party script</strong>
on every page, for a number GA can already give me.</li>
</ul>
<p>So I consolidated onto the Worker. GA's all-time count comes from a <em>standard</em> report (not
realtime), so the Worker makes a second call and returns both numbers:</p>
<div class="language-js codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-js codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token comment" style="color:rgb(98, 114, 164)">// alongside the realtime activeUsers call, a standard report for all-time users:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> data </span><span class="token operator">=</span><span class="token plain"> </span><span class="token keyword control-flow" style="color:rgb(189, 147, 249);font-style:italic">await</span><span class="token plain"> </span><span class="token function" style="color:rgb(80, 250, 123)">gaReport</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">env</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> token</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'runReport'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token literal-property property">dateRanges</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token literal-property property">startDate</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'2020-01-01'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> </span><span class="token literal-property property">endDate</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'today'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token literal-property property">metrics</span><span class="token operator">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token literal-property property">name</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">'totalUsers'</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> total </span><span class="token operator">=</span><span class="token plain"> </span><span class="token known-class-name class-name">Number</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">data</span><span class="token operator">?.</span><span class="token plain">rows</span><span class="token operator">?.</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token number">0</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token operator">?.</span><span class="token plain">metricValues</span><span class="token operator">?.</span><span class="token punctuation" style="color:rgb(248, 248, 242)">[</span><span class="token number">0</span><span class="token punctuation" style="color:rgb(248, 248, 242)">]</span><span class="token operator">?.</span><span class="token plain">value </span><span class="token operator">??</span><span class="token plain"> </span><span class="token number">0</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token comment" style="color:rgb(98, 114, 164)">// the Worker response becomes: { active, total }</span><br></div></code></pre></div></div>
<p>The frontend now reads <code>active</code> (online) and <code>total</code> (visitors) from a single fetch, and the
GoatCounter script and account are gone.</p>
<ul>
<li class=""><strong>Concept:</strong> don't run two tools for one job unless each <em>earns</em> its place. GoatCounter
would have earned it if I weren't already on GA — cookieless analytics is a real, distinct
value. Here it was redundant, and a redundant dependency is pure maintenance cost. The
cheapest integration is the one you don't keep.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="the-pitfalls-the-part-that-actually-cost-time">The pitfalls (the part that actually cost time)<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#the-pitfalls-the-part-that-actually-cost-time" class="hash-link" aria-label="Direct link to The pitfalls (the part that actually cost time)" title="Direct link to The pitfalls (the part that actually cost time)" translate="no">​</a></h2>
<h3 class="anchor anchorTargetStickyNavbar_jZnc" id="pitfall-1-npm-audit-fix---force-wrecked-the-toolchain">Pitfall 1: <code>npm audit fix --force</code> wrecked the toolchain<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#pitfall-1-npm-audit-fix---force-wrecked-the-toolchain" class="hash-link" aria-label="Direct link to pitfall-1-npm-audit-fix---force-wrecked-the-toolchain" title="Direct link to pitfall-1-npm-audit-fix---force-wrecked-the-toolchain" translate="no">​</a></h3>
<p><code>npm install</code> for Wrangler succeeded, but it reported a few "vulnerabilities," so I reflexively
ran <code>npm audit fix --force</code>. That made a <strong>major-version</strong> change and ended up <em>downgrading</em>
Wrangler to a very old release, which dragged in an old <code>better-sqlite3</code> that has <strong>no
prebuilt binary for Node 24</strong> — so it tried to compile from C++ source and exploded with
<code>C++20 or later required</code>.</p>
<ul>
<li class=""><strong>Cause:</strong> <code>--force</code> is allowed to make breaking version changes to silence advisories.</li>
<li class=""><strong>Fix:</strong> <code>rm -rf node_modules package-lock.json</code>, pin a current <code>wrangler</code> (<code>^4</code>), reinstall,
and <strong>don't</strong> run <code>audit fix</code> here.</li>
<li class=""><strong>Concept:</strong> an <code>npm audit</code> advisory describes a risk for <em>some</em> usage of a package. The ones
here were in <strong>esbuild's dev server</strong> — code that never ships to the deployed Worker. The
advisory being real doesn't mean <em>my</em> path is exposed. <code>--force</code> "fixing" a non-applicable
advisory did far more damage than the advisory ever could.</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_jZnc" id="pitfall-2-wrangler-secret-put-takes-the-name-not-the-value">Pitfall 2: <code>wrangler secret put</code> takes the NAME, not the value<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#pitfall-2-wrangler-secret-put-takes-the-name-not-the-value" class="hash-link" aria-label="Direct link to pitfall-2-wrangler-secret-put-takes-the-name-not-the-value" title="Direct link to pitfall-2-wrangler-secret-put-takes-the-name-not-the-value" translate="no">​</a></h3>
<p>I ran <code>wrangler secret put blog-ga-data-api@...iam.gserviceaccount.com</code>, pasted a value at the
prompt, and "succeeded" — having created a secret whose <strong>name</strong> was the email.</p>
<ul>
<li class=""><strong>Cause:</strong> the argument to <code>secret put</code> is the secret's key; the value is read from the
interactive prompt afterward.</li>
<li class=""><strong>Fix:</strong> <code>wrangler secret delete "&lt;the wrong name&gt;"</code>, then
<code>wrangler secret put GA_CLIENT_EMAIL</code> and paste the email at the prompt.</li>
<li class=""><strong>Concept:</strong> the secret name must exactly match what the code reads (<code>env.GA_CLIENT_EMAIL</code>).
A secret with the right value but the wrong name is invisible to the Worker.</li>
</ul>
<h3 class="anchor anchorTargetStickyNavbar_jZnc" id="pitfall-3-ga-403-permission_denied">Pitfall 3: GA 403 <code>PERMISSION_DENIED</code><a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#pitfall-3-ga-403-permission_denied" class="hash-link" aria-label="Direct link to pitfall-3-ga-403-permission_denied" title="Direct link to pitfall-3-ga-403-permission_denied" translate="no">​</a></h3>
<p>First real request to the Worker returned:</p>
<div class="language-json codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-json codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token property">"active"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token null keyword" style="color:rgb(189, 147, 249);font-style:italic">null</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"> </span><span class="token property">"error"</span><span class="token operator">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"GA API 403: ...User does not have sufficient permissions for this property..."</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<ul>
<li class=""><strong>Cause:</strong> the JWT, token exchange, and the API call <strong>all worked</strong> — the service account
simply hadn't been granted access to the GA property yet.</li>
<li class=""><strong>Fix:</strong> GA Admin → Property access management → add the service account's <code>client_email</code> as
<strong>Viewer</strong>, wait a minute, retry.</li>
<li class=""><strong>Concept:</strong> a 403 <em>here</em> is reassuring — it means auth succeeded and you reached the
property; it's purely an authorization (access-grant) gap, not a broken key. Read the error
status: <code>PERMISSION_DENIED</code> (no access) is a different problem from <code>UNAUTHENTICATED</code> (bad
token) or <code>SERVICE_DISABLED</code> (API not enabled).</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="verify">Verify<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#verify" class="hash-link" aria-label="Direct link to Verify" title="Direct link to Verify" translate="no">​</a></h2>
<p>Curl the Worker (or just open it). Once the service account has access you should get:</p>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">curl https://&lt;your-worker&gt;.workers.dev</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"># {"active":0,"total":0}</span><br></div></code></pre></div></div>
<p><code>0</code> is correct when nobody's currently on the site — <code>null</code> would mean something upstream is
still wrong. A real number proves the whole chain.</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="summary">Summary<a href="https://forklore.dev/blog/ga4-realtime-online-on-a-static-site#summary" class="hash-link" aria-label="Direct link to Summary" title="Direct link to Summary" translate="no">​</a></h2>
<table><thead><tr><th>Piece</th><th>When it runs</th><th>Where it's configured</th><th>Secret?</th></tr></thead><tbody><tr><td>posts / tags / last post</td><td>build time</td><td>computed from blog frontmatter</td><td>no</td></tr><tr><td>page tracking (gtag)</td><td>runtime</td><td><code>gtag</code> preset option (Measurement ID)</td><td>no</td></tr><tr><td>online + visitors</td><td>runtime</td><td>Worker (<code>runRealtimeReport</code> + <code>runReport</code>)</td><td>—</td></tr><tr><td>Property ID / Measurement ID</td><td>—</td><td><code>wrangler.toml</code> / <code>docusaurus.config.ts</code></td><td>no</td></tr><tr><td>service-account key</td><td>runtime</td><td>Cloudflare secret (<code>GA_PRIVATE_KEY</code>)</td><td><strong>yes</strong></td></tr></tbody></table>
<p>The mental model worth keeping: <strong>static numbers get baked at build; live numbers need a
runtime source, and any source that requires a private key needs a proxy in front of it.</strong> The
proxy is small, but it's the whole reason the secret never leaks.</p>]]></content:encoded>
            <category>devops</category>
            <category>cloudflare-workers</category>
            <category>google-analytics</category>
            <category>Docusaurus</category>
        </item>
        <item>
            <title><![CDATA[Kubernetes Pod CrashLoopBackOff]]></title>
            <link>https://forklore.dev/blog/k8s-pod-crashloopbackoff</link>
            <guid>https://forklore.dev/blog/k8s-pod-crashloopbackoff</guid>
            <pubDate>Sat, 13 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[A step-by-step workflow for debugging Kubernetes Pod CrashLoopBackOff: from kubectl logs / describe to readiness probes and exit codes.]]></description>
            <content:encoded><![CDATA[<p>A Pod STATUS of CrashLoopBackOff isn't a single error — <br>
<!-- -->it's an <strong>umbrella term</strong> for "the container keeps failing to start and kubelet keeps restarting it." <br>
<!-- -->The same STATUS can have completely different root causes. <br>
<!-- -->Today I deliberately created three failure scenarios; the way you investigate each one differs.</p>
<!-- -->
<div class="theme-admonition theme-admonition-note admonition_HE9s alert alert--secondary"><div class="admonitionHeading_EnnA"><span class="admonitionIcon_d9Hy"><svg viewBox="0 0 14 16"><path fill-rule="evenodd" d="M6.3 5.69a.942.942 0 0 1-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 0 1-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"></path></svg></span>note</div><div class="admonitionContent_FwsI"><p>CrashLoopBackOff is the state where kubelet "restarts the container repeatedly, backing off between restarts," which assumes a restartPolicy that restarts (<code>Always</code> or <code>OnFailure</code>). The examples below all use <code>restartPolicy: Never</code> so the root cause shows up quickly — a failed container <strong>won't be restarted</strong>, and the STATUS stops directly at <code>Error</code> or <code>OOMKilled</code>. But <strong>the way you find the root cause is exactly the same</strong>. To see CrashLoopBackOff with your own eyes, drop <code>--restart=Never</code> and use the default <code>Always</code>; RESTARTS will keep climbing.</p></div></div>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="pitfall-1-command-not-found">Pitfall 1: command not found<a href="https://forklore.dev/blog/k8s-pod-crashloopbackoff#pitfall-1-command-not-found" class="hash-link" aria-label="Direct link to Pitfall 1: command not found" title="Direct link to Pitfall 1: command not found" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl run crash-cmd --image=busybox --restart=Never -- /bin/sh -c "notacommand"</span><br></div></code></pre></div></div>
<ul>
<li class="">Symptom: under <code>restart=Never</code> the STATUS stops at <code>Error</code> with RESTARTS at 0; switch to the default <code>Always</code> and you'll see <code>CrashLoopBackOff</code> with RESTARTS incrementing.</li>
<li class="">Investigation: <code>kubectl logs crash-cmd --previous</code> → shows <code>notacommand: not found</code>.</li>
<li class="">Cause: the entrypoint command itself doesn't exist, so the container exits the moment it starts.</li>
<li class="">Fix: use a valid command.</li>
<li class="">Concept: CrashLoop means "keeps restarting," <strong>not</strong> "can't be scheduled." The container did start — it just dies immediately — so you can see the application-level error in the logs.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="pitfall-2-missing-env">Pitfall 2: missing env<a href="https://forklore.dev/blog/k8s-pod-crashloopbackoff#pitfall-2-missing-env" class="hash-link" aria-label="Direct link to Pitfall 2: missing env" title="Direct link to Pitfall 2: missing env" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl run crash-env --image=busybox --restart=Never -- /bin/sh -c 'test -n "$REQUIRED" || (echo "REQUIRED missing"; exit 2)'</span><br></div></code></pre></div></div>
<ul>
<li class="">Symptom: fails the same way (<code>Error</code> under <code>Never</code>, <code>CrashLoopBackOff</code> under <code>Always</code>).</li>
<li class="">Investigation: <code>kubectl logs crash-env --previous</code> → <code>REQUIRED missing</code>, exit code 2.</li>
<li class="">Cause: the program self-checks for a required environment variable at startup and exits on its own when it's missing.</li>
<li class="">Fix: supply the env var (<code>--env=REQUIRED=x</code>, or inject it via the Deployment's <code>env:</code> / a ConfigMap / a Secret).</li>
<li class="">Concept: any non-zero exit code is treated as a failure and triggers a restart. For this kind of "the app decides to exit itself" error, the logs usually explain why — checking the logs first is the easiest path.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="pitfall-3-oomkilled">Pitfall 3: OOMKilled<a href="https://forklore.dev/blog/k8s-pod-crashloopbackoff#pitfall-3-oomkilled" class="hash-link" aria-label="Direct link to Pitfall 3: OOMKilled" title="Direct link to Pitfall 3: OOMKilled" translate="no">​</a></h2>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain"># set an extremely small memory limit, then run something that eats memory</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">kubectl apply -f - &lt;&lt;'EOF'</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">apiVersion: v1</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">kind: Pod</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">metadata: { name: crash-oom }</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">spec:</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  restartPolicy: Never</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  containers:</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  - name: stress</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    image: polinux/stress</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    resources: { limits: { memory: "20Mi" } }</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    command: ["stress"]</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">    args: ["--vm","1","--vm-bytes","100M","--vm-hang","1"]</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">EOF</span><br></div></code></pre></div></div>
<ul>
<li class="">Symptom: STATUS shows <code>OOMKilled</code>, but <code>kubectl logs --previous</code> <strong>reveals nothing</strong> (it may even be empty).</li>
<li class="">Investigation: <code>kubectl describe pod crash-oom</code> → look at <strong>Last State: Terminated, Reason: OOMKilled</strong>, exit code 137.</li>
<li class="">Cause: the container's usage exceeded the memory limit and the kernel OOM killer killed it — the app didn't exit on its own, it was killed by an external force.</li>
<li class="">Fix: raise <code>resources.limits.memory</code>, or fix the real memory leak / reduce usage.</li>
<li class="">Concept: <strong>OOMKilled never makes it into the logs</strong>, because logs are the "container's stdout/stderr," whereas OOM is the kernel sending SIGKILL directly — the app never gets a chance to print anything. You have to look at <strong>Last State</strong> via <code>describe</code>. Exit code 137 = 128 + 9 (SIGKILL).</li>
</ul>
<hr>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="debug-summary">Debug Summary<a href="https://forklore.dev/blog/k8s-pod-crashloopbackoff#debug-summary" class="hash-link" aria-label="Direct link to Debug Summary" title="Direct link to Debug Summary" translate="no">​</a></h2>
<table><thead><tr><th>STATUS</th><th>Container State</th><th>Kubectl</th><th>Root Cause</th></tr></thead><tbody><tr><td>CrashLoopBackOff / Error</td><td>Terminated (then restarting)</td><td><code>kubectl logs --previous</code></td><td>command not found, missing env, exit code != 0</td></tr><tr><td>OOMKilled</td><td>Terminated</td><td><code>kubectl describe</code> -&gt; Last State</td><td>Out Of Memory</td></tr><tr><td>ImagePullBackOff</td><td>Waiting</td><td><code>kubectl describe</code> -&gt; Events</td><td>Wrong image name/tag, missing imagePullSecrets</td></tr></tbody></table>]]></content:encoded>
            <category>kubernetes</category>
            <category>devops</category>
        </item>
        <item>
            <title><![CDATA[Why I chose Helm over Kustomize]]></title>
            <link>https://forklore.dev/blog/why-helm-over-kustomize</link>
            <guid>https://forklore.dev/blog/why-helm-over-kustomize</guid>
            <pubDate>Mon, 08 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Comparing Helm and Kustomize on templating, ecosystem, release management, and team ramp-up — and why I ended up choosing Helm.]]></description>
            <content:encoded><![CDATA[<p>Two weeks ago I gave myself a task: write a Helm chart from scratch and really get Helm. <br>
<!-- -->After finishing the exercise, I also did a tool-selection exercise alongside it, <br>
<!-- -->thinking about why I'd choose Helm when Kustomize is simpler. <br>
<!-- -->I asked myself "what capability does this situation actually need," letting the requirements pick the tool, rather than letting features pick me.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="what-i-need-let-the-requirements-pick-the-tool-not-the-features-pick-me">What I need: let the requirements pick the tool, not the features pick me<a href="https://forklore.dev/blog/why-helm-over-kustomize#what-i-need-let-the-requirements-pick-the-tool-not-the-features-pick-me" class="hash-link" aria-label="Direct link to What I need: let the requirements pick the tool, not the features pick me" title="Direct link to What I need: let the requirements pick the tool, not the features pick me" translate="no">​</a></h2>
<p>I broke the requirements into four questions to decide between Kustomize and Helm:</p>
<ul>
<li class=""><strong>Do I need logic?</strong>
<ul>
<li class="">The moment a manifest needs a conditional or a loop, Kustomize can't do it — it has no <code>if</code> / <code>range</code>.</li>
</ul>
</li>
<li class=""><strong>Do I need a lifecycle?</strong>
<ul>
<li class="">install / upgrade / rollback, with release state stored in a namespace Secret in the cluster — this is the core of Helm.</li>
<li class="">Kustomize has no concept of a release; rolling back means <code>git revert</code> and re-apply.</li>
</ul>
</li>
<li class=""><strong>Do I need dependencies?</strong>
<ul>
<li class="">My existing redis and postgresql are both charts; integrating them as subcharts is directly supported by Helm.</li>
</ul>
</li>
<li class=""><strong>Do I need hooks?</strong>
<ul>
<li class="">Running a Job pre-install is something Helm has hooks for; Kustomize doesn't.</li>
</ul>
</li>
</ul>
<p>If my need were just "apply the same manifest to three environments, tweaking replicas and the image," I'd pick Kustomize — that's where it's at its cleanest.</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="the-exercise-the-chart-i-wrote-myself">The exercise: the chart I wrote myself<a href="https://forklore.dev/blog/why-helm-over-kustomize#the-exercise-the-chart-i-wrote-myself" class="hash-link" aria-label="Direct link to The exercise: the chart I wrote myself" title="Direct link to The exercise: the chart I wrote myself" translate="no">​</a></h2>
<p>I practiced by writing a chart that actually runs. The point isn't the few <code>helm create</code> commands (anyone can scaffold...). <br>
<!-- -->The real learning is reshaping that "does everything" default chart into your own. I added:</p>
<ul>
<li class="">a ConfigMap</li>
<li class="">a pre-install hook Job</li>
<li class="">a <code>values.schema.json</code> to block bad input.</li>
</ul>
<p><code>helm create</code> gives you an all-capable skeleton that isn't yours; turning it into your own — the whole process is really about deciding what to throw away, what to add, and what to keep.</p>
<blockquote>
<p>Repo: <a href="https://github.com/shoudevops/helm-practice" target="_blank" rel="noopener noreferrer" class="">https://github.com/shoudevops/helm-practice</a></p>
</blockquote>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="removing-a-feature--deleting-a-file-the-tentacles-of-autoscaling">Removing a feature ≠ deleting a file: the tentacles of autoscaling<a href="https://forklore.dev/blog/why-helm-over-kustomize#removing-a-feature--deleting-a-file-the-tentacles-of-autoscaling" class="hash-link" aria-label="Direct link to Removing a feature ≠ deleting a file: the tentacles of autoscaling" title="Direct link to Removing a feature ≠ deleting a file: the tentacles of autoscaling" translate="no">​</a></h2>
<p>The most eye-opening practice came from "deleting a feature." <br>
<!-- -->I wanted to remove autoscaling; my instinct was to just delete <code>hpa.yaml</code>. <br>
<!-- -->I thought I was done, but rendering blew up — that line in <code>deployment.yaml</code>, <code>{{- if not .Values.autoscaling.enabled }}</code>, was still referencing a value I thought no longer existed, straight to a nil pointer. <br>
<!-- -->It turned out this feature's tentacles weren't only in hpa.yaml: values.yaml had its settings, NOTES.txt mentioned it, and the deployment's replicas logic was hooked into it too. <br>
<!-- -->I chased them one by one, and only after I got to that line in the deployment was it truly clean.</p>
<p>What I learned from the process: deleting a feature means chasing every one of its references in values / other templates / NOTES; deleting the file is only step one. <br>
<!-- -->Incidentally, I also stepped on two landmines along the way:</p>
<ul>
<li class="">never let secrets into git</li>
<li class=""><code>--reset-values</code> wipes out the custom values you've accumulated.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="when-id-switch-to-kustomize">When I'd switch to Kustomize<a href="https://forklore.dev/blog/why-helm-over-kustomize#when-id-switch-to-kustomize" class="hash-link" aria-label="Direct link to When I'd switch to Kustomize" title="Direct link to When I'd switch to Kustomize" translate="no">​</a></h2>
<p>This post isn't saying Helm is some cure-all. <br>
<!-- -->For third-party apps I'll install with Helm; <br>
<!-- -->but for purely environment-to-environment differences, I'll use Kustomize overlays — or even render with <code>helm template</code> first and finish with a Kustomize patch.</p>
<p><strong>The criterion is still the same one:</strong></p>
<ul>
<li class="">once I need conditionals or rollback, Kustomize is out; if it's just changing a few values, I don't need Helm.</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="wrap-up">Wrap-up<a href="https://forklore.dev/blog/why-helm-over-kustomize#wrap-up" class="hash-link" aria-label="Direct link to Wrap-up" title="Direct link to Wrap-up" translate="no">​</a></h2>
<p>Back to the question at the very start: why not Kustomize? <br>
<!-- -->Because tool-selection skill was never about reciting which tool has which features — it's about honestly asking yourself "what do I need" first, then letting the requirements pick the tool. <br>
<!-- -->Next I want to pick up secret management — SOPS or External Secrets Operator — which is yet another "let the requirements pick the tool" topic.</p>]]></content:encoded>
            <category>helm</category>
            <category>kustomize</category>
            <category>kubernetes</category>
            <category>devops</category>
        </item>
        <item>
            <title><![CDATA[helm diff? Installing one plugin, I hit 4 pitfalls in a row]]></title>
            <link>https://forklore.dev/blog/helm-diff-pitfalls-day10</link>
            <guid>https://forklore.dev/blog/helm-diff-pitfalls-day10</guid>
            <pubDate>Sun, 07 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[Installing the helm-diff plugin, I hit 4 pitfalls in a row: version compatibility, CRD diffs, kube context, and output mismatches — with fixes.]]></description>
            <content:encoded><![CDATA[<p>Today's learning task started out simple: install the <a href="https://github.com/databus23/helm-diff" target="_blank" rel="noopener noreferrer" class="">helm-diff</a> plugin, try <code>helm diff upgrade</code> once, and understand why you'd want it in production.</p>
<p>Instead, from the <code>helm plugin install</code> moment onward, I hit 4 pitfalls in a row — and the last one let me actually witness how "the diff shows no change, but the real deployment changes things" happens. Here's the whole process, written down.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="pitfall-1-the-plugin-wont-install--you-need---verifyfalse">Pitfall 1: the plugin won't install — you need <code>--verify=false</code><a href="https://forklore.dev/blog/helm-diff-pitfalls-day10#pitfall-1-the-plugin-wont-install--you-need---verifyfalse" class="hash-link" aria-label="Direct link to pitfall-1-the-plugin-wont-install--you-need---verifyfalse" title="Direct link to pitfall-1-the-plugin-wont-install--you-need---verifyfalse" translate="no">​</a></h2>
<p>I got stuck on the very first step:</p>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">helm plugin install https://github.com/databus23/helm-diff</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"># Error: plugin source does not support verification. Use --verify=false to skip verification</span><br></div></code></pre></div></div>
<p>The reason is that Helm v3.18+ <strong>requires GPG provenance signature verification on plugins by default</strong>, but most community plugins (helm-diff included) don't ship a signature file with their releases, so verification is guaranteed to fail. Adding the flag as the message suggests fixes it:</p>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">helm plugin install https://github.com/databus23/helm-diff --verify=false</span><br></div></code></pre></div></div>
<p>What <code>--verify</code> does is supply-chain protection — confirming the plugin you downloaded hasn't been tampered with and really comes from the author. On a local lab, with the source being a well-known official repo, <code>--verify=false</code> is reasonable. But the right instinct to build is this: <strong>when production / CI installs plugins automatically, "skip verification" shouldn't be a casual habit.</strong> A safer approach is to clone first, review the contents, pin a specific tag/commit, then install from the local path. Plenty of supply-chain attacks start exactly with "it's just a tool, whatever."</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="pitfall-2-the-schema-blocks-the-image-tag">Pitfall 2: the schema blocks the image tag<a href="https://forklore.dev/blog/helm-diff-pitfalls-day10#pitfall-2-the-schema-blocks-the-image-tag" class="hash-link" aria-label="Direct link to Pitfall 2: the schema blocks the image tag" title="Direct link to Pitfall 2: the schema blocks the image tag" translate="no">​</a></h2>
<p>After installing, the first diff spat out this:</p>
<div class="language-text codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-text codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Error: at '/image/tag': got number, want string</span><br></div></code></pre></div></div>
<p>The day before, my practice had added a <code>values.schema.json</code> to the chart, which requires <code>image.tag</code> to be a string. The problem was in <code>values.yaml</code>:</p>
<div class="language-yaml codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-yaml codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">image</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">tag</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token number">1.27</span><br></div></code></pre></div></div>
<p>Without quotes, YAML parses <code>1.27</code> as a <strong>number</strong>, and the schema blocks it on comparison. Adding quotes makes it pass:</p>
<div class="language-yaml codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-yaml codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">image</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  </span><span class="token key atrule">tag</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(255, 121, 198)">"1.27"</span><br></div></code></pre></div></div>
<p>This is actually a hidden bug that schema validation caught for me: an image tag should always be quoted. If values like <code>1.20</code> or <code>1.0</code> get treated as numbers, <code>1.20</code> gets truncated to <code>1.2</code> and <code>1.0</code> becomes <code>1</code>, and you pull the wrong image on deploy. One innocent-looking quote prevents production pulling the wrong version.</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="pitfall-3-helm-diff-produces-no-output-at-all">Pitfall 3: <code>helm diff</code> produces no output at all<a href="https://forklore.dev/blog/helm-diff-pitfalls-day10#pitfall-3-helm-diff-produces-no-output-at-all" class="hash-link" aria-label="Direct link to pitfall-3-helm-diff-produces-no-output-at-all" title="Direct link to pitfall-3-helm-diff-produces-no-output-at-all" translate="no">​</a></h2>
<p>Once it passed the schema, the diff output stumped me — sometimes it printed <code>-/+</code>, sometimes it was completely blank. After sorting it out, two key points went into my notes:</p>
<p><strong><code>-</code> is the state of the existing release in the cluster (live), <code>+</code> is the state it will become after applying.</strong> It's not "file vs file," it's "current vs after-apply."</p>
<p><strong>Value precedence (highest to lowest):</strong> <code>--set</code> &gt; <code>-f/--values</code> files &gt; the chart's <code>values.yaml</code> &gt; subchart values. At one point I kept editing <code>values.yaml</code> and the diff didn't respond — it was being overridden by a <code>--set</code> on the command line. This is the most common "I changed it but nothing happened" trap.</p>
<p>The most useful investigation technique is to manually pull apart what helm-diff does under the hood:</p>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain"># live: the manifest actually stored for the release on the cluster</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">helm get manifest demo -n demo | grep -E "^kind:|replicas:"</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"># new: the result of re-rendering with values.yaml</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">helm template demo ~/helm-lab/myfirst-chart | grep -E "^kind:|replicas:"</span><br></div></code></pre></div></div>
<p><code>helm get manifest</code> is the "current state," <code>helm template</code> is "recomputed from files" — compare the two sides and you know which object differs. Once I understood this layer, helm-diff stopped being a black box to me.</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="pitfall-4-the-diff-shows-no-change-but-helm-upgrade-actually-changes-things">Pitfall 4: the diff shows "no change," but <code>helm upgrade</code> actually changes things<a href="https://forklore.dev/blog/helm-diff-pitfalls-day10#pitfall-4-the-diff-shows-no-change-but-helm-upgrade-actually-changes-things" class="hash-link" aria-label="Direct link to pitfall-4-the-diff-shows-no-change-but-helm-upgrade-actually-changes-things" title="Direct link to pitfall-4-the-diff-shows-no-change-but-helm-upgrade-actually-changes-things" translate="no">​</a></h2>
<p>This is the most important one today, and the one most worth writing into my notes.</p>
<p>The situation was this: <code>values.yaml</code> had <code>replicaCount: 1</code>, but the release on the cluster had been deployed and stored with <code>replicaCount: 2</code>. I ran:</p>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">helm diff upgrade demo ~/helm-lab/myfirst-chart -n demo</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"># (no output at all)</span><br></div></code></pre></div></div>
<p>The diff showed "no change." But when I manually compared live and template, the Deployment's replicas were clearly 2 on one side and 1 on the other — there shouldn't be no difference, right?</p>
<p>Adding <code>--reset-values</code> and running it again, the truth came out:</p>
<div class="language-bash codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-bash codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">helm diff upgrade demo ~/helm-lab/myfirst-chart -n demo --reset-values</span><br></div></code></pre></div></div>
<div class="language-diff codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-diff codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token plain">demo, demo-myfirst-chart, Deployment (apps) has changed:</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">  spec:</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">-   replicas: 2</span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain">+   replicas: 1</span><br></div></code></pre></div></div>
<p>The root of the problem is that <strong>the two commands have inconsistent default behavior</strong>:</p>
<ul>
<li class=""><code>helm diff upgrade</code> by default <strong>reuses the old values stored in the release</strong> (reuse), so it computes with <code>replicaCount: 2</code>, and comparing against live shows "no change," naturally.</li>
<li class="">But Helm 3's <code>helm upgrade</code> defaults to <strong>reset</strong> — it uses the chart's <code>values.yaml</code> (i.e. 1) plus whatever overrides you give.</li>
</ul>
<p>In other words: the diff lied to me. It said "no change," but if I'd actually run <code>helm upgrade demo .</code>, replicas would change from 2 to 1. <strong>The diff not matching the real deployment is exactly where real incidents come from</strong> — you see no change in the diff, merge with confidence, and the actual apply moves resources.</p>
<p>From this comes an iron rule: <strong>when you use helm-diff in CI, its value-resolution mode must match the command you actually deploy with.</strong> Three related flags to remember:</p>
<ul>
<li class=""><code>--reuse-values</code>: reuse the old release's values, only merging in this run's overrides</li>
<li class=""><code>--reset-values</code>: discard the old values, use the chart's <code>values.yaml</code> entirely</li>
<li class=""><code>--reset-then-reuse-values</code> (v3.14+): reset first, then layer the old overrides back on — usually the safest</li>
</ul>
<p>One incidental finding: hooks (like the pre-install Job I added on Day 7, or the <code>helm create</code> default test-connection Pod) <strong>don't show up in the diff by default</strong>, because they aren't part of the persistent desired state, so comparing them is meaningless. So it's normal for <code>helm template</code> to show them while the diff doesn't mention them.</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="wrap-up">Wrap-up<a href="https://forklore.dev/blog/helm-diff-pitfalls-day10#wrap-up" class="hash-link" aria-label="Direct link to Wrap-up" title="Direct link to Wrap-up" translate="no">​</a></h2>
<p>Installing a single plugin unexpectedly became the most rewarding lesson of the day. The four pitfalls strung together are really the same theme: <strong>a tool's defaults aren't necessarily the behavior you actually want.</strong></p>
<ul>
<li class=""><code>helm plugin install</code> requires signature verification by default → it may not install</li>
<li class="">YAML treats <code>1.27</code> as a number by default → the schema blocks it</li>
<li class=""><code>--set</code> has the highest precedence by default → it overrides the file you edited</li>
<li class=""><code>helm diff</code> defaults to reuse, <code>helm upgrade</code> defaults to reset → the diff lies to you</li>
</ul>
<p>Use DevOps tools long enough and you find that what really bites you usually isn't "not knowing how to use it," but "assuming it does A when it actually defaults to B." In the next post, wrapping up the Helm chart, I'll put together a deep dive on the Helm vs Kustomize trade-off and include the chart I wrote over these two weeks.</p>]]></content:encoded>
            <category>helm</category>
            <category>kubernetes</category>
            <category>devops</category>
            <category>helm-diff</category>
        </item>
        <item>
            <title><![CDATA[Week 1 of the Helm Template Guide: the 3 concepts that finally clicked]]></title>
            <link>https://forklore.dev/blog/helm-week1-3-key-concepts</link>
            <guid>https://forklore.dev/blog/helm-week1-3-key-concepts</guid>
            <pubDate>Mon, 01 Jun 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[helm template vs helm install --dry-run, values scoping, and why ArgoCD prefers helm template — the 3 Helm concepts beginners most often confuse.]]></description>
            <content:encoded><![CDATA[<p>I started picking up Helm this week — reading the official template guide while writing a chart by hand, and also taking apart one of Bitnami's big charts. Here are the three concepts I found most important, written down as notes to my future self.</p>
<!-- -->
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="1-helm-template-is-not-the-same-as-helm-install---dry-run">1. <code>helm template</code> is not the same as <code>helm install --dry-run</code><a href="https://forklore.dev/blog/helm-week1-3-key-concepts#1-helm-template-is-not-the-same-as-helm-install---dry-run" class="hash-link" aria-label="Direct link to 1-helm-template-is-not-the-same-as-helm-install---dry-run" title="Direct link to 1-helm-template-is-not-the-same-as-helm-install---dry-run" translate="no">​</a></h2>
<p><code>helm template</code> only does local rendering: it reads <code>Chart.yaml</code> and <code>templates/</code>, substitutes the values from <code>values.yaml</code> into the templates, and prints plain YAML — it never touches the cluster.</p>
<p><code>helm install --dry-run</code>, on top of rendering, sends the result to the kube-apiserver for schema validation and adds Helm's own metadata. So dry-run can catch problems that <code>template</code> can't see (for example, a field with the wrong type). This is also why ArgoCD uses <code>helm template</code> and then applies the output itself, rather than <code>helm install</code> — it wants predictable plain YAML and doesn't want Helm taking over cluster state.</p>
<p>When paired with ArgoCD, ArgoCD prefers <code>helm template</code> over <code>helm install</code>, because it wants to be the single declarative state manager. It treats Helm as nothing more than a rendering tool, instead of handing control of cluster state over to Helm's release mechanism.</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="2-values-with-a-default--quote-pipeline">2. Values with a <code>default</code> / <code>quote</code> pipeline<a href="https://forklore.dev/blog/helm-week1-3-key-concepts#2-values-with-a-default--quote-pipeline" class="hash-link" aria-label="Direct link to 2-values-with-a-default--quote-pipeline" title="Direct link to 2-values-with-a-default--quote-pipeline" translate="no">​</a></h2>
<p>Reading a value directly with <code>{{ .Values.image.repository }}</code> renders to a blank when the value is missing — an easy trap. A pipeline makes it much safer:</p>
<div class="language-yaml codeBlockContainer_T_JQ theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_aAef"><pre tabindex="0" class="prism-code language-yaml codeBlock_eeCl thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_Syga"><div class="token-line" style="color:#F8F8F2"><span class="token key atrule">replicas</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> .Values.replicaCount </span><span class="token punctuation" style="color:rgb(248, 248, 242)">|</span><span class="token plain"> default 1 </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#F8F8F2"><span class="token plain"></span><span class="token key atrule">image</span><span class="token punctuation" style="color:rgb(248, 248, 242)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token punctuation" style="color:rgb(248, 248, 242)">{</span><span class="token plain"> .Values.image.repository </span><span class="token punctuation" style="color:rgb(248, 248, 242)">|</span><span class="token plain"> default "nginx" </span><span class="token punctuation" style="color:rgb(248, 248, 242)">|</span><span class="token plain"> quote </span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><span class="token punctuation" style="color:rgb(248, 248, 242)">}</span><br></div></code></pre></div></div>
<p>A pipeline is evaluated <strong>left to right</strong>, passing the result down one stage at a time: <code>default "nginx"</code> supplies a fallback when the value is missing, and <code>quote</code> then turns the result into a quoted string. When a value is missing, <code>default</code> saves you from rendering an empty string that would break the whole YAML.</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="3-helpers-and-the-two-dashes----">3. Helpers and the two dashes <code>{{- -}}</code><a href="https://forklore.dev/blog/helm-week1-3-key-concepts#3-helpers-and-the-two-dashes----" class="hash-link" aria-label="Direct link to 3-helpers-and-the-two-dashes----" title="Direct link to 3-helpers-and-the-two-dashes----" translate="no">​</a></h2>
<p><code>_helpers.tpl</code> is where you put reusable snippets (DRY). Helpers are almost always written as <code>{{- define "x" -}}</code> rather than <code>{{ define "x" }}</code>; those leading and trailing dashes (<code>-</code>) <strong>trim the surrounding whitespace, including newlines</strong>, so the string produced by <code>include</code> stays clean and doesn't wreck your YAML indentation.</p>
<p>By contrast, the <code>_helpers.tpl</code> that <code>helm create</code> generates is very simple, because it only deals with basic objects like deployment / service / ingress. Bitnami's helpers are far more complex because it uses many labels and has many values that need to be referenced repeatedly.</p>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="side-note-the-mdx-traps-i-hit">Side note: the MDX traps I hit<a href="https://forklore.dev/blog/helm-week1-3-key-concepts#side-note-the-mdx-traps-i-hit" class="hash-link" aria-label="Direct link to Side note: the MDX traps I hit" title="Direct link to Side note: the MDX traps I hit" translate="no">​</a></h2>
<p>Writing this post in Docusaurus, I ran into two traps:</p>
<ul>
<li class=""><strong>HTML comments break the build.</strong> <code>&lt;!-- --&gt;</code> is invalid in MDX; you have to use the MDX comment <code>{/* */}</code> instead.</li>
<li class=""><strong>Bare double braces get treated as JSX.</strong> A <code>{{ .Values.xxx }}</code> in the prose, if left unwrapped, makes MDX try to evaluate it as a JavaScript expression and error out — so wrap it in a code fence or inline code (backticks).</li>
</ul>
<h2 class="anchor anchorTargetStickyNavbar_jZnc" id="wrap-up">Wrap-up<a href="https://forklore.dev/blog/helm-week1-3-key-concepts#wrap-up" class="hash-link" aria-label="Direct link to Wrap-up" title="Direct link to Wrap-up" translate="no">​</a></h2>
<p>Getting these three things straight in week one — template vs install, the values pipeline, and helpers with their dashes — is essentially getting familiar with the skeleton of Helm's rendering flow. Next week I'll keep building on top: dependency subcharts, hooks, and then actually installing the chart into a cluster to run a round of install / upgrade / rollback.</p>]]></content:encoded>
            <category>helm</category>
            <category>kubernetes</category>
            <category>devops</category>
        </item>
    </channel>
</rss>