<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: iapilgrim</title>
    <description>The latest articles on DEV Community by iapilgrim (@pilgrim2go).</description>
    <link>https://dev.to/pilgrim2go</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F36351%2Fd3ac7864-d102-4f7a-abfa-c2ca03495434.png</url>
      <title>DEV Community: iapilgrim</title>
      <link>https://dev.to/pilgrim2go</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pilgrim2go"/>
    <language>en</language>
    <item>
      <title>Tutorial: Role-Based Access Control for a Node/Express API, Backed by Keycloak</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Tue, 21 Jul 2026 03:00:20 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/tutorial-role-based-access-control-for-a-nodeexpress-api-backed-by-keycloak-15h3</link>
      <guid>https://dev.to/pilgrim2go/tutorial-role-based-access-control-for-a-nodeexpress-api-backed-by-keycloak-15h3</guid>
      <description>&lt;p&gt;This is a hands-on walkthrough for &lt;strong&gt;kc-rbac-api&lt;/strong&gt; — module 04 in a series&lt;br&gt;
building up Keycloak features one small, runnable project at a time. By the&lt;br&gt;
end you'll have a Node/Express API with three roles enforced at the&lt;br&gt;
endpoint level, two interchangeable ways of validating incoming tokens, and&lt;br&gt;
a full test suite proving the denials work as intended, not just the happy&lt;br&gt;
path.&lt;/p&gt;

&lt;p&gt;Everything below assumes Docker + Docker Compose, Node.js, &lt;code&gt;curl&lt;/code&gt;, and&lt;br&gt;
&lt;code&gt;python3&lt;/code&gt; (used by the setup script for JSON parsing — nothing else).&lt;/p&gt;
&lt;h2&gt;
  
  
  What you're building
&lt;/h2&gt;

&lt;p&gt;An API with three roles — &lt;code&gt;viewer&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt; — where each endpoint&lt;br&gt;
enforces a specific minimum role, not just "logged in or not":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;viewer&lt;/th&gt;
&lt;th&gt;editor&lt;/th&gt;
&lt;th&gt;admin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST /api/documents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PUT /api/documents/:id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DELETE /api/documents/:id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/admin/stats&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /api/whoami&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice &lt;code&gt;DELETE&lt;/code&gt; breaks the simple "editor and up" pattern — it's&lt;br&gt;
admin-only. That's deliberate, and it's the detail worth testing carefully&lt;br&gt;
later on.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1 — Start Keycloak and the API
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make start   &lt;span class="c"&gt;# docker compose up -d --build: Keycloak + this API's container&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Give Keycloak about 30 seconds to finish booting, then provision everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make setup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs &lt;code&gt;keycloak/setup.sh&lt;/code&gt;, which — entirely through Keycloak's Admin&lt;br&gt;
REST API, no clicking through the console — creates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a realm called &lt;code&gt;rbac-demo&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;three realm roles: &lt;code&gt;viewer&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a confidential client &lt;code&gt;kc-rbac-api&lt;/code&gt; with an audience mapper (more on that
below)&lt;/li&gt;
&lt;li&gt;three demo users, one per role, and writes the client secret into &lt;code&gt;.env&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Username&lt;/th&gt;
&lt;th&gt;Password&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;alice&lt;/td&gt;
&lt;td&gt;alice&lt;/td&gt;
&lt;td&gt;viewer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bob&lt;/td&gt;
&lt;td&gt;bob&lt;/td&gt;
&lt;td&gt;editor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;carol&lt;/td&gt;
&lt;td&gt;carol&lt;/td&gt;
&lt;td&gt;admin&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Confirm the API is up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:3000/health
&lt;span class="c"&gt;# {"status":"ok","validationMode":"local"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2 — Understand the two ways to validate a token
&lt;/h2&gt;

&lt;p&gt;Every protected route sits behind one of two middlewares, and which one is&lt;br&gt;
active is a config toggle (&lt;code&gt;TOKEN_VALIDATION_MODE&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;) — not a code&lt;br&gt;
change:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;src/middleware/jwtAuth.js&lt;/code&gt; (&lt;code&gt;local&lt;/code&gt;)&lt;/strong&gt; verifies the token's RS256&lt;br&gt;
signature locally, against Keycloak's JWKS endpoint (cached via&lt;br&gt;
&lt;code&gt;jwks-rsa&lt;/code&gt;), then checks &lt;code&gt;iss&lt;/code&gt;, &lt;code&gt;aud&lt;/code&gt;, and &lt;code&gt;exp&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getSigningKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;RS256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fast — one JWKS fetch, then it's pure CPU per request after that. The&lt;br&gt;
tradeoff: if Keycloak revokes the token or the user logs out server-side,&lt;br&gt;
this middleware has no way to know. The token stays "valid" here until it&lt;br&gt;
naturally expires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;src/middleware/introspectAuth.js&lt;/code&gt; (&lt;code&gt;introspect&lt;/code&gt;)&lt;/strong&gt; instead POSTs the&lt;br&gt;
token to Keycloak's &lt;code&gt;/token/introspect&lt;/code&gt; endpoint (RFC 7662) on every&lt;br&gt;
request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;axios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;introspectionUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;token_inactive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slower — one extra network round trip per call — but revocation is&lt;br&gt;
immediate: Keycloak reports &lt;code&gt;active: false&lt;/code&gt; the moment a token is no&lt;br&gt;
longer valid, regardless of its &lt;code&gt;exp&lt;/code&gt;. This mode needs its own client&lt;br&gt;
credentials, since introspection is itself an authenticated endpoint —&lt;br&gt;
only clients that own or trust the token's audience get to ask Keycloak&lt;br&gt;
about it.&lt;/p&gt;

&lt;p&gt;Neither one is "the right answer" on its own — it's a latency-vs-freshness&lt;br&gt;
tradeoff. Short access-token lifespans (this realm is set to 300 seconds)&lt;br&gt;
are the usual way to bound local validation's blind spot without paying&lt;br&gt;
the introspection cost on every single call.&lt;/p&gt;

&lt;p&gt;Try switching modes and watching &lt;code&gt;/api/whoami&lt;/code&gt; reflect it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN_VALIDATION_MODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;introspect npm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/realms/rbac-demo/protocol/openid-connect/token &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=password&amp;amp;client_id=kc-rbac-api&amp;amp;client_secret=&lt;/span&gt;&lt;span class="nv"&gt;$CLIENT_SECRET&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;username=carol&amp;amp;password=carol"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import sys,json;print(json.load(sys.stdin)['access_token'])"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

curl http://localhost:3000/api/whoami &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# "authMode": "introspect"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3 — Enforce roles with two middleware shapes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;src/middleware/rbac.js&lt;/code&gt; gives you two ways to gate a route, and picking&lt;br&gt;
the right one matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Exact allow-list — use when roles AREN'T hierarchical for this endpoint&lt;/span&gt;
&lt;span class="nf"&gt;requireRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Hierarchical — viewer(1) &amp;lt; editor(2) &amp;lt; admin(3)&lt;/span&gt;
&lt;span class="nf"&gt;requireMinRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;editor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requireMinRole&lt;/code&gt; is what powers most of the table above — &lt;code&gt;POST&lt;/code&gt;/&lt;code&gt;PUT&lt;/code&gt;&lt;br&gt;
use &lt;code&gt;requireMinRole("editor")&lt;/code&gt; since editor-and-up should both succeed. But&lt;br&gt;
&lt;code&gt;DELETE&lt;/code&gt; uses &lt;code&gt;requireRole("admin")&lt;/code&gt; explicitly, &lt;em&gt;not&lt;/em&gt;&lt;br&gt;
&lt;code&gt;requireMinRole("admin")&lt;/code&gt; treated as "the top of the chain" by accident —&lt;br&gt;
it's named as an exact requirement on purpose. If you ever add a role that&lt;br&gt;
doesn't nest cleanly into the hierarchy (say, an &lt;code&gt;auditor&lt;/code&gt; who can read&lt;br&gt;
things &lt;code&gt;editor&lt;/code&gt; can't but can't write anything), model it with&lt;br&gt;
&lt;code&gt;requireRole()&lt;/code&gt; allow-lists rather than forcing it into the rank table.&lt;/p&gt;

&lt;p&gt;Roles themselves come out of the token via &lt;code&gt;src/utils/tokenClaims.js&lt;/code&gt;,&lt;br&gt;
which merges Keycloak's &lt;code&gt;realm_access.roles&lt;/code&gt; (realm-wide roles) and&lt;br&gt;
&lt;code&gt;resource_access.&amp;lt;clientId&amp;gt;.roles&lt;/code&gt; (client-scoped roles) into one flat set&lt;br&gt;
— route middleware doesn't care which bucket a role happened to live in.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 4 — Understand why the audience (&lt;code&gt;aud&lt;/code&gt;) check exists
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;keycloak/setup.sh&lt;/code&gt; adds an &lt;code&gt;oidc-audience-mapper&lt;/code&gt; so tokens for this&lt;br&gt;
client carry &lt;code&gt;aud: kc-rbac-api&lt;/code&gt;, and both middlewares reject anything&lt;br&gt;
where that's missing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;aud&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid_audience&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this check, a token minted for a &lt;em&gt;different&lt;/em&gt; API in the same&lt;br&gt;
realm — one with broader service-account scope, say — would pass&lt;br&gt;
signature and issuer checks just fine and get treated as valid here too.&lt;br&gt;
That's the "confused deputy" problem: a token issued for one purpose&lt;br&gt;
getting replayed against a resource server it was never meant for. &lt;code&gt;aud&lt;/code&gt;&lt;br&gt;
is the check that closes that gap.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 5 — Run the full allow/deny matrix
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;This runs &lt;code&gt;tests/test.sh&lt;/code&gt;, a self-contained bash suite: it fetches real&lt;br&gt;
tokens for alice/bob/carol, hits every endpoint as every role, and asserts&lt;br&gt;
both the HTTP status &lt;em&gt;and&lt;/em&gt; the JSON error code — so a 403 for the wrong&lt;br&gt;
reason still fails the test.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;── 6. DELETE /api/documents/:id (admin only) ──
✓ PASS  [DENY] viewer cannot delete   (got 403, error=insufficient_role)
✓ PASS  [DENY] editor cannot delete   (got 403, error=insufficient_role)
✓ PASS  [ALLOW] admin can delete      (got 204)

════════════════════════════════════════════════
  Results: 25 passed, 0 failed
════════════════════════════════════════════════
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's mode-aware: one assertion checks for &lt;code&gt;invalid_token&lt;/code&gt; under local&lt;br&gt;
validation but &lt;code&gt;token_inactive&lt;/code&gt; under introspection, since Keycloak's&lt;br&gt;
introspection endpoint doesn't distinguish "malformed" from&lt;br&gt;
"merely-unrecognized" — both are a correct 401, just different&lt;br&gt;
vocabulary. Exits non-zero on any failure, so it's safe to drop straight&lt;br&gt;
into CI.&lt;/p&gt;

&lt;p&gt;Prefer clicking through requests instead of reading a script? The same&lt;br&gt;
matrix is in &lt;code&gt;http/requests.http&lt;/code&gt; (VS Code REST Client — token requests&lt;br&gt;
save into named variables the later requests reuse automatically) and as&lt;br&gt;
a Postman collection + environment in &lt;code&gt;postman/&lt;/code&gt;, with &lt;code&gt;pm.test&lt;/code&gt;&lt;br&gt;
assertions on every request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6 — Poke around the Keycloak Admin Console
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://localhost:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Username: &lt;code&gt;admin&lt;/code&gt; / Password: &lt;code&gt;admin&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Realm: &lt;code&gt;rbac-demo&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Client &lt;code&gt;kc-rbac-api&lt;/code&gt; → &lt;strong&gt;Roles&lt;/strong&gt; tab has &lt;code&gt;viewer&lt;/code&gt;/&lt;code&gt;editor&lt;/code&gt;/&lt;code&gt;admin&lt;/code&gt; →
&lt;strong&gt;Client scopes&lt;/strong&gt; → &lt;strong&gt;Dedicated scopes&lt;/strong&gt; → &lt;strong&gt;Mappers&lt;/strong&gt; has the audience
mapper you just saw enforced in code&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worth clicking into a user (say, &lt;code&gt;carol&lt;/code&gt;) and looking at &lt;strong&gt;Role mapping&lt;/strong&gt;&lt;br&gt;
to see the realm role assignment that ends up in &lt;code&gt;realm_access.roles&lt;/code&gt; on&lt;br&gt;
her token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you take this to production
&lt;/h2&gt;

&lt;p&gt;A few deliberate simplifications worth knowing about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;This demo uses Resource Owner Password Credentials&lt;/strong&gt; (&lt;code&gt;grant_type=password&lt;/code&gt;)
purely so &lt;code&gt;setup.sh&lt;/code&gt;, the &lt;code&gt;.http&lt;/code&gt; file, and Postman can fetch tokens with
one curl call per user. Browser-facing apps should use &lt;strong&gt;Authorization
Code + PKCE&lt;/strong&gt; instead — keep &lt;code&gt;directAccessGrantsEnabled: false&lt;/code&gt; for those
clients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The in-memory &lt;code&gt;documents&lt;/code&gt; array resets on restart.&lt;/strong&gt; Swap it for a real
datastore before this goes anywhere near real traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;requireMinRole&lt;/code&gt; assumes a strictly linear hierarchy.&lt;/strong&gt; It works great
for &lt;code&gt;viewer &amp;lt; editor &amp;lt; admin&lt;/code&gt;, but don't force a non-nesting role into it
— use &lt;code&gt;requireRole()&lt;/code&gt; allow-lists instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the pieces live
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;keycloak/setup.sh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Realm, roles, client + audience mapper, 3 demo users — all via Admin REST API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/middleware/jwtAuth.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Local JWT validation (JWKS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/middleware/introspectAuth.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;RFC 7662 introspection validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/middleware/rbac.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;requireRole()&lt;/code&gt; / &lt;code&gt;requireMinRole()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/routes/documents.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The CRUD resource from the table above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/routes/admin.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Admin-only endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;src/routes/whoami.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Debug endpoint — inspect resolved roles/claims for your own token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tests/test.sh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The 25-case bash suite from Step 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;http/requests.http&lt;/code&gt; / &lt;code&gt;postman/*.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Same matrix, interactive&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                          ┌─────────────┐
  client ── access_token ─►  kc-rbac-api │
                          └──────┬──────┘
                                 │
                validationMode = local          validationMode = introspect
                                 │                            │
                    verify sig vs JWKS              POST /token/introspect
                    check iss/aud/exp               (client_id + client_secret)
                    read roles from claims           check active + aud
                                 │                            │
                                 └─────────► requireMinRole() / requireRole() ◄─────────┘
                                                       │
                                              200 / 201 / 204  or  403 insufficient_role
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two validation strategies, one audience check standing between them and&lt;br&gt;
"any signed token gets in," and a role hierarchy that's explicit about&lt;br&gt;
where it stops being hierarchical. That's the whole module.&lt;/p&gt;

&lt;p&gt;Next up in the series: &lt;strong&gt;module 05 — Identity Brokering&lt;/strong&gt;, wiring up two&lt;br&gt;
Keycloak realms in one Compose stack so a "corp" realm can broker into a&lt;br&gt;
"partner" realm via OIDC, without needing an actual external IdP to test&lt;br&gt;
against.&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>tutorial</category>
      <category>node</category>
      <category>oauth</category>
    </item>
    <item>
      <title>Keycloak from Scratch, Part 2 — Authorization Code + PKCE, Built by Hand</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 12:42:11 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/keycloak-from-scratch-part-2-authorization-code-pkce-built-by-hand-1e5b</link>
      <guid>https://dev.to/pilgrim2go/keycloak-from-scratch-part-2-authorization-code-pkce-built-by-hand-1e5b</guid>
      <description>&lt;p&gt;In Part 1 we logged in by sending a username and password directly&lt;br&gt;
to Keycloak. That's fine for a script, but no browser-facing app should&lt;br&gt;
ever do that — it means the app itself sees the raw password, and it&lt;br&gt;
can't participate in single sign-on. What every real login button should&lt;br&gt;
do instead is &lt;strong&gt;redirect the user to Keycloak&lt;/strong&gt; and get a token handed&lt;br&gt;
back. That's the Authorization Code flow, and for anything that can't&lt;br&gt;
keep a secret (a browser app, a mobile app), it needs a companion called&lt;br&gt;
&lt;strong&gt;PKCE&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We're going to implement it by hand — no &lt;code&gt;keycloak-js&lt;/code&gt;, no library — so&lt;br&gt;
every step is visible instead of hidden behind &lt;code&gt;keycloak.login()&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we're building
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A public Keycloak client (no secret — a browser can't keep one)&lt;/li&gt;
&lt;li&gt;A plain HTML + vanilla JS page that performs the full redirect flow&lt;/li&gt;
&lt;li&gt;A small Flask API that validates the resulting token independently,
without ever talking to Keycloak per request&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Everything from Part 1 (Keycloak running via Docker Compose)&lt;/li&gt;
&lt;li&gt;Python 3 with &lt;code&gt;pip install flask flask-cors "pyjwt[crypto]"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A browser&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  1. Create a new realm and a public client
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Admin Console → &lt;strong&gt;Create realm&lt;/strong&gt; → &lt;code&gt;pkce-demo&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clients&lt;/strong&gt; → &lt;strong&gt;Create client&lt;/strong&gt; → Client ID: &lt;code&gt;pkce-spa-client&lt;/code&gt; → &lt;strong&gt;Next&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client authentication&lt;/strong&gt;: &lt;strong&gt;Off&lt;/strong&gt; — this is what makes it a public
client. There's no secret to steal because there isn't one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capability config&lt;/strong&gt;: check &lt;strong&gt;Standard flow&lt;/strong&gt;, uncheck &lt;strong&gt;Direct access
grants&lt;/strong&gt; (we don't want this client falling back to Part 1's flow) → &lt;strong&gt;Save&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings&lt;/strong&gt; tab:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Valid redirect URIs&lt;/strong&gt;: &lt;code&gt;http://localhost:5173/*&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web origins&lt;/strong&gt;: &lt;code&gt;http://localhost:5173&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advanced&lt;/strong&gt; tab → scroll to &lt;strong&gt;Proof Key for Code Exchange Code
Challenge Method&lt;/strong&gt; → set to &lt;strong&gt;S256&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This last setting is the one that actually enforces PKCE server-side.&lt;br&gt;
  Without it, Keycloak will still &lt;em&gt;accept&lt;/em&gt; a PKCE challenge if the client&lt;br&gt;
  sends one, but it won't &lt;em&gt;require&lt;/em&gt; it — leaving the door open for a&lt;br&gt;
  client that skips PKCE entirely.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Create a role and a user
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Realm roles&lt;/strong&gt; → create &lt;code&gt;spa-user&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Users&lt;/strong&gt; → &lt;strong&gt;Add user&lt;/strong&gt; → username &lt;code&gt;dave&lt;/code&gt;, first/last name &lt;code&gt;Dave&lt;/code&gt;/&lt;code&gt;Demo&lt;/code&gt;
(see Part 1 if you're wondering why that matters)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials&lt;/strong&gt; tab → password &lt;code&gt;dave&lt;/code&gt;, &lt;strong&gt;Temporary: Off&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role mapping&lt;/strong&gt; → assign &lt;code&gt;spa-user&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  3. The concept: what PKCE actually proves
&lt;/h2&gt;

&lt;p&gt;A confidential client proves its identity with a secret. A public client&lt;br&gt;
can't do that — so PKCE proves something different: &lt;strong&gt;that the app&lt;br&gt;
redeeming the authorization code is the same app that started the login&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser                                          Keycloak
  │  code_verifier = random(64 bytes)                │
  │  code_challenge = SHA256(code_verifier)          │
  ├──── GET /auth?code_challenge=... ───────────────►│
  │◄─── redirect back with ?code=... ─────────────────┤
  ├──── POST /token                                   │
  │        code=..., code_verifier=... ──────────────►│  (recomputes SHA256(code_verifier),
  │◄─── access_token, id_token ───────────────────────┤   compares to the original challenge)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;code_verifier&lt;/code&gt; never leaves the browser tab until the final token&lt;br&gt;
request. If an attacker somehow intercepts the redirect and grabs the&lt;br&gt;
&lt;code&gt;code&lt;/code&gt; (say, via a malicious app registered against the same custom&lt;br&gt;
URL scheme on mobile), they still can't redeem it — they don't have the&lt;br&gt;
verifier, and they can't derive it from the challenge, because SHA-256&lt;br&gt;
doesn't run backwards.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Generating the challenge (app.js)
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;base64UrlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/=+$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;randomString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;arr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;base64UrlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Nothing exotic — &lt;code&gt;crypto.subtle&lt;/code&gt; (native to every modern browser) does&lt;br&gt;
the hashing, and the verifier is just cryptographically random bytes.&lt;/p&gt;
&lt;h2&gt;
  
  
  5. Kicking off the login
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codeVerifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codeChallenge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base64UrlEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;codeVerifier&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;randomString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// This never leaves the browser until step 6.&lt;/span&gt;
  &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pkce_code_verifier&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;codeVerifier&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pkce_state&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pkce-spa-client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;response_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;code_challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;codeChallenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;code_challenge_method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;S256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="s2"&gt;`http://localhost:8080/realms/pkce-demo/protocol/openid-connect/auth?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;state&lt;/code&gt; parameter is a separate, unrelated safety net: a random value&lt;br&gt;
checked on the way back to guard against CSRF — someone tricking your&lt;br&gt;
browser into completing an authorization flow you didn't start.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. Handling the redirect back
&lt;/h2&gt;

&lt;p&gt;Keycloak sends the browser back to your &lt;code&gt;redirect_uri&lt;/code&gt; with &lt;code&gt;?code=...&amp;amp;state=...&lt;/code&gt;&lt;br&gt;
in the URL. This runs on page load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;returnedState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expectedState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pkce_state&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;returnedState&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expectedState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;state mismatch — possible CSRF, aborting&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;codeVerifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pkce_code_verifier&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;grant_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization_code&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pkce-spa-client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;code_verifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;codeVerifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;http://localhost:8080/realms/pkce-demo/protocol/openid-connect/token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;access_token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;sessionStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id_token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;history&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceState&lt;/span&gt;&lt;span class="p"&gt;({},&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/index.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keycloak re-derives &lt;code&gt;SHA256(code_verifier)&lt;/code&gt; server-side and compares it&lt;br&gt;
to the &lt;code&gt;code_challenge&lt;/code&gt; it received in step 5. If they match, and the&lt;br&gt;
&lt;code&gt;code&lt;/code&gt; hasn't already been redeemed or expired, you get tokens back.&lt;/p&gt;
&lt;h2&gt;
  
  
  7. Validating the token on the API side
&lt;/h2&gt;

&lt;p&gt;The API never talks to Keycloak per request — it fetches Keycloak's&lt;br&gt;
public signing keys once (JWKS) and verifies signatures locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jsonify&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask_cors&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CORS&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PyJWKClient&lt;/span&gt;

&lt;span class="n"&gt;ISSUER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8080/realms/pkce-demo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;CLIENT_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pkce-spa-client&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;jwks_client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PyJWKClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ISSUER&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/protocol/openid-connect/certs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;CORS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;origins&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:5173&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;me&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missing bearer token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;

    &lt;span class="n"&gt;signing_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwks_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_signing_key_from_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signing_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ISSUER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verify_aud&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Keycloak's default audience is "account", not the client id — check
&lt;/span&gt;    &lt;span class="c1"&gt;# azp (authorized party) instead, which is always the requesting client.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;azp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unexpected azp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;jsonify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preferred_username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;preferred_username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;realm_access&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;roles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because Keycloak signs tokens asymmetrically (RS256), the API only ever&lt;br&gt;
needs the &lt;em&gt;public&lt;/em&gt; half of the key pair — it can validate tokens without&lt;br&gt;
ever holding a shared secret with Keycloak.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 api/server.py &amp;amp;            &lt;span class="c"&gt;# :5001&lt;/span&gt;
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; http.server 5173 &amp;amp;      &lt;span class="c"&gt;# :5173, serving the SPA&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:5173&lt;/code&gt;, click &lt;strong&gt;Log in&lt;/strong&gt;, authenticate as&lt;br&gt;
&lt;code&gt;dave&lt;/code&gt;/&lt;code&gt;dave&lt;/code&gt;, and you should land back on the page with decoded ID&lt;br&gt;
token and access token claims printed, plus a working call to&lt;br&gt;
&lt;code&gt;/api/me&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened here
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public client, no secret&lt;/strong&gt; — PKCE substitutes possession of a
one-time verifier for a shared secret the client can't keep anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;state&lt;/code&gt; and &lt;code&gt;code_verifier&lt;/code&gt; are solving different problems&lt;/strong&gt; — CSRF
vs. authorization-code interception — don't conflate them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asymmetric JWT validation&lt;/strong&gt; means your API and your identity
provider never need to share a secret at all, unlike the client-secret
approach from Part 1.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;strong&gt;Next up, Part 3&lt;/strong&gt;: back to server-to-server auth — client credentials&lt;br&gt;
grants, service accounts, and when Direct Grant (Part 1) is and isn't&lt;br&gt;
the right tool for machine identities.&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>oauth</category>
      <category>pkce</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Keycloak from Scratch, Part 1 — Realms, Roles, Clients &amp; Users (No Docker Magic, Just Clicks)</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Mon, 20 Jul 2026 12:40:51 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/keycloak-from-scratch-part-1-realms-roles-clients-users-no-docker-magic-just-clicks-50fb</link>
      <guid>https://dev.to/pilgrim2go/keycloak-from-scratch-part-1-realms-roles-clients-users-no-docker-magic-just-clicks-50fb</guid>
      <description>&lt;p&gt;If you've ever inherited a Keycloak instance and had no idea what a&lt;br&gt;
"realm" actually isolates, or why some clients have secrets and others&lt;br&gt;
don't, this post is the ground floor. We're building one small realm by&lt;br&gt;
hand, in the Admin Console — no automation scripts, no realm-export JSON&lt;br&gt;
to skim past. Just enough clicking to make every concept stick.&lt;/p&gt;

&lt;p&gt;By the end you'll have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A dedicated realm&lt;/li&gt;
&lt;li&gt;Three roles representing a basic permission tier (&lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;viewer&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A confidential client configured for direct login&lt;/li&gt;
&lt;li&gt;Three users, each mapped to one role&lt;/li&gt;
&lt;li&gt;A working &lt;code&gt;curl&lt;/code&gt; command that logs in as each user and shows you their
role inside a decoded JWT&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Docker + Docker Compose&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;python3&lt;/code&gt; (for a one-line JWT decode — no library needed)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  1. Start Keycloak
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;keycloak&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;quay.io/keycloak/keycloak:26.7.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;keycloak-fundamentals&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;start-dev&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;KEYCLOAK_ADMIN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admin&lt;/span&gt;
      &lt;span class="na"&gt;KEYCLOAK_ADMIN_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;admin&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Give it about 30 seconds, then open &lt;strong&gt;&lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt;&lt;/strong&gt; and log in&lt;br&gt;
with &lt;code&gt;admin&lt;/code&gt; / &lt;code&gt;admin&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;start-dev&lt;/code&gt; is a dev-mode flag — it skips TLS and some production&lt;br&gt;
checks so you can iterate quickly. Never run this flag in production.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  2. Create a realm
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;realm&lt;/strong&gt; is Keycloak's top-level tenancy boundary — its own users,&lt;br&gt;
roles, clients, and even its own login theme, fully isolated from every&lt;br&gt;
other realm on the same server. Think of it as a separate mini&lt;br&gt;
directory service you can spin up per environment, per product, or per&lt;br&gt;
customer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Top-left realm dropdown → &lt;strong&gt;Create realm&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Realm name: &lt;code&gt;fundamentals&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Create&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  3. Create three realm roles
&lt;/h2&gt;

&lt;p&gt;Roles are how you express "what can this identity do" without hardcoding&lt;br&gt;
usernames into your authorization logic. We're using &lt;strong&gt;realm roles&lt;/strong&gt;&lt;br&gt;
here — visible to every client in the realm, as opposed to &lt;strong&gt;client&lt;br&gt;
roles&lt;/strong&gt;, which are scoped to one specific client. (We'll hit client&lt;br&gt;
roles in a later post when we build a proper RBAC API.)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Left sidebar → &lt;strong&gt;Realm roles&lt;/strong&gt; → &lt;strong&gt;Create role&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Create three, one at a time: &lt;code&gt;admin&lt;/code&gt;, &lt;code&gt;editor&lt;/code&gt;, &lt;code&gt;viewer&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  4. Create a confidential client
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Left sidebar → &lt;strong&gt;Clients&lt;/strong&gt; → &lt;strong&gt;Create client&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Client ID: &lt;code&gt;kc-fundamentals-app&lt;/code&gt; → &lt;strong&gt;Next&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client authentication&lt;/strong&gt;: toggle &lt;strong&gt;On&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the setting that makes a client "confidential" — it gets a&lt;br&gt;
  secret, and Keycloak requires that secret before it'll hand out a&lt;br&gt;
  token. A &lt;strong&gt;public client&lt;/strong&gt; (toggle off) has no secret at all, because&lt;br&gt;
  anything shipped to a browser or mobile app can't actually keep one&lt;br&gt;
  secret. We'll build a public client in Part 2.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capability config&lt;/strong&gt;: check &lt;strong&gt;Direct access grants&lt;/strong&gt;, leave
&lt;strong&gt;Standard flow&lt;/strong&gt; on if you like (harmless either way for this demo) → &lt;strong&gt;Save&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Go to the &lt;strong&gt;Credentials&lt;/strong&gt; tab and copy the &lt;strong&gt;Client secret&lt;/strong&gt; — you'll
paste it into the &lt;code&gt;curl&lt;/code&gt; command later&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  5. Create three users
&lt;/h2&gt;

&lt;p&gt;Repeat this for &lt;code&gt;alice&lt;/code&gt;, &lt;code&gt;bob&lt;/code&gt;, and &lt;code&gt;carol&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Users&lt;/strong&gt; → &lt;strong&gt;Add user&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Username: &lt;code&gt;alice&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First name&lt;/strong&gt;: &lt;code&gt;Alice&lt;/code&gt;, &lt;strong&gt;Last name&lt;/strong&gt;: &lt;code&gt;Demo&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't skip this. Keycloak has a built-in "Update Profile" required&lt;br&gt;
  action that checks profile completeness &lt;strong&gt;dynamically, at login&lt;br&gt;
  time&lt;/strong&gt; — not something you'll see if you inspect the user's stored&lt;br&gt;
  data beforehand. An empty first/last name silently blocks login later&lt;br&gt;
  with a generic &lt;code&gt;"Account is not fully set up"&lt;/code&gt; error that has nothing&lt;br&gt;
  to do with the password you set. This one cost me an embarrassing&lt;br&gt;
  amount of debugging the first time — save yourself the trouble.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Create&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials&lt;/strong&gt; tab → &lt;strong&gt;Set password&lt;/strong&gt; → &lt;code&gt;alice&lt;/code&gt; → toggle
&lt;strong&gt;Temporary: Off&lt;/strong&gt; → &lt;strong&gt;Save&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Temporary" means the user is forced to change it on next login —&lt;br&gt;
  fine for humans clicking through a browser flow, but it'll break a&lt;br&gt;
  scripted or API-driven login every time, since there's no browser to&lt;br&gt;
  serve the "set a new password" screen. Off, for this demo.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Role mapping&lt;/strong&gt; tab → &lt;strong&gt;Assign role&lt;/strong&gt; → pick &lt;code&gt;admin&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repeat for &lt;code&gt;bob&lt;/code&gt; → &lt;code&gt;editor&lt;/code&gt; and &lt;code&gt;carol&lt;/code&gt; → &lt;code&gt;viewer&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  6. One more thing to check: OTP
&lt;/h2&gt;

&lt;p&gt;Some Keycloak versions ship the built-in &lt;code&gt;direct grant&lt;/code&gt; authentication&lt;br&gt;
flow with its conditional-OTP branch misconfigured as &lt;code&gt;Required&lt;/code&gt; instead&lt;br&gt;
of &lt;code&gt;Conditional&lt;/code&gt;. If that's the case on your instance, &lt;strong&gt;every&lt;/strong&gt; login&lt;br&gt;
attempt from a user without an OTP device configured — which is all&lt;br&gt;
three of ours — gets rejected before the password is even checked.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt; (left sidebar) → &lt;strong&gt;Flows&lt;/strong&gt; tab → select &lt;strong&gt;direct grant&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Find the OTP-related row at the &lt;strong&gt;top level&lt;/strong&gt; (often labeled something
like &lt;em&gt;"Direct Grant - Conditional OTP"&lt;/em&gt;)&lt;/li&gt;
&lt;li&gt;If it's not already &lt;code&gt;Conditional&lt;/code&gt; or &lt;code&gt;Disabled&lt;/code&gt;, set it to &lt;strong&gt;Disabled&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  7. Log in and inspect the token
&lt;/h2&gt;

&lt;p&gt;This is a &lt;strong&gt;Direct Access Grant&lt;/strong&gt; (also called Resource Owner Password&lt;br&gt;
Credentials, or ROPC) — the client trades a username and password&lt;br&gt;
directly for a token, no browser redirect involved. It's convenient for&lt;br&gt;
this fundamentals demo and for machine-to-machine scripts, but it means&lt;br&gt;
the client sees the raw password, so it's a poor fit for anything&lt;br&gt;
user-facing. (Part 2 covers the flow you actually want for a browser app:&lt;br&gt;
Authorization Code + PKCE.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/realms/fundamentals/protocol/openid-connect/token &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/x-www-form-urlencoded"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=password"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id=kc-fundamentals-app"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret=PASTE_YOUR_SECRET_HERE"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"username=alice"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"password=alice"&lt;/span&gt; | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns an &lt;code&gt;access_token&lt;/code&gt;, decode its payload to see the role&lt;br&gt;
claim in the flesh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/realms/fundamentals/protocol/openid-connect/token &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"grant_type=password"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_id=kc-fundamentals-app"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"client_secret=PASTE_YOUR_SECRET_HERE"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"username=alice"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"password=alice"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import sys, json, base64
token = json.load(sys.stdin)['access_token']
payload = token.split('.')[1]
payload += '=' * (-len(payload) % 4)
print(json.dumps(json.loads(base64.urlsafe_b64decode(payload)), indent=2))
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"realm_access"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"roles"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"admin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"default-roles-fundamentals"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"offline_access"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uma_authorization"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;admin&lt;/code&gt; is the one we assigned. The other three are Keycloak's own&lt;br&gt;
housekeeping roles every user gets by default.&lt;/p&gt;

&lt;p&gt;Repeat with &lt;code&gt;bob&lt;/code&gt;/&lt;code&gt;bob&lt;/code&gt; and &lt;code&gt;carol&lt;/code&gt;/&lt;code&gt;carol&lt;/code&gt; — you should see &lt;code&gt;editor&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;viewer&lt;/code&gt; respectively.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened here
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Realm isolation&lt;/strong&gt;: &lt;code&gt;fundamentals&lt;/code&gt; never sees or touches any other
realm's users, clients, or roles — that's the whole point of realms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confidential client + Direct Grant&lt;/strong&gt;: fine for this demo and for
trusted server-side/CLI tooling, wrong for a browser SPA (that's Part 2).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role claims propagate automatically&lt;/strong&gt;: once a realm role is assigned
to a user, Keycloak embeds it in every token that user gets, with zero
extra configuration — this is what your API or gateway will check
against later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Troubleshooting: "Account is not fully set up"
&lt;/h2&gt;

&lt;p&gt;If you hit this, it's one of exactly two causes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;OTP required in the direct grant flow&lt;/strong&gt; (see step 6 above)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incomplete user profile&lt;/strong&gt; — missing first/last name (see step 5).
This one is genuinely invisible from the outside: it's evaluated live
at login, not stored anywhere you can query beforehand.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Check both. They're independent, and fixing only one can still leave you&lt;br&gt;
stuck.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Next up, Part 2&lt;/strong&gt;: we swap Direct Grant for the flow every real&lt;br&gt;
browser app should use — Authorization Code with PKCE — and build a&lt;br&gt;
vanilla-JS SPA that implements the whole thing by hand, no auth library,&lt;br&gt;
so every step of the exchange is visible.&lt;/p&gt;

</description>
      <category>keycloak</category>
      <category>iam</category>
      <category>oauth</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Master the ECR Lifecycle: Automating Image Cleanup</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Mon, 23 Mar 2026 11:29:23 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/master-the-ecr-lifecycle-automating-image-cleanup-oh4</link>
      <guid>https://dev.to/pilgrim2go/master-the-ecr-lifecycle-automating-image-cleanup-oh4</guid>
      <description>&lt;h2&gt;
  
  
  Why Lifecycle Policies?
&lt;/h2&gt;

&lt;p&gt;Every time your CI/CD pipeline runs &lt;code&gt;docker push&lt;/code&gt;, you're adding roughly 200MB–1GB of data to your AWS bill. Without a policy, that data sits there forever. Lifecycle policies allow you to define rules like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Keep only the last 10 images."&lt;/li&gt;
&lt;li&gt;"Delete anything older than 14 days."&lt;/li&gt;
&lt;li&gt;"Expire untagged images immediately."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The "Perfect" Policy for Dev/Staging
&lt;/h2&gt;

&lt;p&gt;For most teams, the best balance between "safety" and "savings" is a two-rule policy. &lt;/p&gt;

&lt;h3&gt;
  
  
  1. The "Untagged" Rule (Priority 1)
&lt;/h3&gt;

&lt;p&gt;When you push a new image with the same tag (like &lt;code&gt;:latest&lt;/code&gt;), the old image becomes "untagged." These are orphaned layers that serve no purpose. Delete them after 24 hours.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The "Age" Rule (Priority 2)
&lt;/h3&gt;

&lt;p&gt;Delete any image that hasn't been pushed in the last 30 days. This ensures that even if you stop a project, its storage costs don't haunt you for years.&lt;/p&gt;

&lt;h3&gt;
  
  
  The JSON Configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rulePriority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cleanup orphaned/untagged images"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"selection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"tagStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"untagged"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"countType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sinceImagePushed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"countUnit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"countNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"expire"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rulePriority"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Delete images older than 30 days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"selection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"tagStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"any"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"countType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sinceImagePushed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"countUnit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"days"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"countNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"expire"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Critical Concepts: &lt;code&gt;sinceImagePushed&lt;/code&gt; vs. &lt;code&gt;imageCountMoreThan&lt;/code&gt;
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sinceImagePushed&lt;/code&gt;&lt;/strong&gt;: Best for time-based compliance. (e.g., "We only keep 30 days of history").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;imageCountMoreThan&lt;/code&gt;&lt;/strong&gt;: Best for storage predictability. (e.g., "I only ever want to pay for 10 images per repo").&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to Test Without Breaking Production
&lt;/h2&gt;

&lt;p&gt;The biggest fear is deleting an image that a production server might need during an auto-scaling event. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "Lifecycle Policy Preview"&lt;/strong&gt; is your best friend. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Navigate to your ECR Repo -&amp;gt; &lt;strong&gt;Lifecycle Policy&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Actions&lt;/strong&gt; -&amp;gt; &lt;strong&gt;Create Preview&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;AWS will generate a list of exactly which images &lt;em&gt;would&lt;/em&gt; be deleted if the policy ran right now. &lt;strong&gt;Check this list against your current production version before hitting save.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The 24-Hour Rule
&lt;/h2&gt;

&lt;p&gt;Remember: ECR policies are &lt;strong&gt;not instantaneous&lt;/strong&gt;. After you click "Save," AWS schedules a background task. It usually takes &lt;strong&gt;24 hours&lt;/strong&gt; for the images to actually disappear from your console and for the storage metrics to drop in CloudWatch.&lt;/p&gt;




&lt;h3&gt;
  
  
  Pro-Tip: Infrastructure as Code (Terraform)
&lt;/h3&gt;

&lt;p&gt;Don't click around the console for 50 repos. Add this to your Terraform module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ecr_lifecycle_policy"&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;repository&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_ecr_repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;

  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
{
    "rules": [
        {
            "rulePriority": 1,
            "description": "Keep last 30 images",
            "selection": {
                "tagStatus": "any",
                "countType": "imageCountMoreThan",
                "countNumber": 30
            },
            "action": {
                "type": "expire"
            }
        }
    ]
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>aws</category>
      <category>ecr</category>
      <category>cleanup</category>
    </item>
    <item>
      <title>Deploying a Base Sepolia Node with Docker</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Sat, 21 Mar 2026 07:22:07 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/deploying-a-base-sepolia-node-with-docker-4db4</link>
      <guid>https://dev.to/pilgrim2go/deploying-a-base-sepolia-node-with-docker-4db4</guid>
      <description>&lt;p&gt;We're using QuickNode for Base Sepolia. This post is how to setup our own node.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Prerequisites
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docker &amp;amp; Docker Compose&lt;/strong&gt; installed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;L1 RPC Endpoint:&lt;/strong&gt; A synced Ethereum Sepolia node (e.g., Geth + Lighthouse).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;L1 Beacon Endpoint:&lt;/strong&gt; Required for post-Canyon/Ecotone consensus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardware:&lt;/strong&gt; Minimum 16GB RAM and 1.5TB+ NVMe SSD.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  2. The Automated Setup Script
&lt;/h3&gt;

&lt;p&gt;We use a wrapper script to handle directory creation, JWT generation, and repository patching. This ensures your local paths are correctly mapped into the Docker containers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Save as &lt;code&gt;setup-base-sepolia.sh&lt;/code&gt;:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"🚀 Initializing Base Sepolia Setup..."&lt;/span&gt;

&lt;span class="nv"&gt;BASE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/node-data/testnet/base-sepolia"&lt;/span&gt;
&lt;span class="nv"&gt;REPO_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/opt/base-node"&lt;/span&gt;

&lt;span class="c"&gt;# 1. Create directory layout&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"📁 Creating data directories..."&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASE_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;/&lt;span class="o"&gt;{&lt;/span&gt;op-geth,op-node,shared&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# 2. Generate the JWT Secret (The "Handshake" Key)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASE_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/shared/jwt.txt"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"🔑 Generating 32-byte hex JWT..."&lt;/span&gt;
  &lt;span class="c"&gt;# CRITICAL: No 0x prefix, no newlines.&lt;/span&gt;
  openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32 | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASE_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;/shared/jwt.txt
  &lt;span class="nb"&gt;chmod &lt;/span&gt;644 &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASE_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;/shared/jwt.txt
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# 3. Clone the official Base Node Repo&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"📦 Cloning base-org/node..."&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REPO_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;git clone https://github.com/base-org/node.git &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REPO_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;fi

&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;REPO_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;# 4. Create .env.sepolia&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"⚙️ Writing environment configuration..."&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .env.sepolia &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
# L1 Endpoints (Replace with your actual L1 IPs)
OP_NODE_L1_ETH_RPC=http://testnet:8585
OP_NODE_L1_BEACON=http://testnet-lighthouse:5052

# L2 Execution &amp;amp; Auth
OP_NODE_L2_ENGINE_RPC=http://execution:8551
OP_NODE_L2_ENGINE_AUTH=jwt
OP_NODE_L2_ENGINE_JWT_SECRET=/shared/jwt.txt

# Network &amp;amp; Sync
OP_NODE_NETWORK=base-sepolia
OP_NODE_SYNC_MODE=snap
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# 5. Patch docker-compose.yml for custom volumes&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"🛠 Patching docker-compose volumes..."&lt;/span&gt;
&lt;span class="c"&gt;# Map op-geth and op-node to host paths&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s|${HOST_DATA_DIR}:/data|/node-data/testnet/base-sepolia/op-geth:/data|g'&lt;/span&gt; docker-compose.yml
&lt;span class="c"&gt;# Inject the shared JWT volume into both services&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'/volumes:/a \      - /node-data/testnet/base-sepolia/shared:/shared'&lt;/span&gt; docker-compose.yml

&lt;span class="c"&gt;# 6. Start the stack&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"🚀 Starting containers..."&lt;/span&gt;
docker compose &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env.sepolia up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3. Critical Configuration Check
&lt;/h3&gt;

&lt;p&gt;For the node to function, your &lt;strong&gt;entrypoint scripts&lt;/strong&gt; inside the repo must use the correct environment variables.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;execution-entrypoint&lt;/code&gt;&lt;/strong&gt;: Ensure &lt;code&gt;--authrpc.jwtsecret="$OP_NODE_L2_ENGINE_JWT_SECRET"&lt;/code&gt; is set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;op-node-entrypoint&lt;/code&gt;&lt;/strong&gt;: Ensure &lt;code&gt;--l2.jwt-secret="$OP_NODE_L2_ENGINE_JWT_SECRET"&lt;/code&gt; is set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Networking&lt;/strong&gt;: Ensure &lt;code&gt;--rpc.addr=0.0.0.0&lt;/code&gt; is set in both to allow external monitoring.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  4. Troubleshooting &amp;amp; Mastery 🔍
&lt;/h3&gt;

&lt;p&gt;If your node isn't syncing, check these three layers in order:&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Layer 1: The JWT Handshake&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;If &lt;code&gt;op-node&lt;/code&gt; cannot talk to &lt;code&gt;op-geth&lt;/code&gt;, you will see &lt;code&gt;401 Unauthorized&lt;/code&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check:&lt;/strong&gt; &lt;code&gt;docker exec execution-1 cat /shared/jwt.txt&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Ensure the file is exactly 64 characters long. If it contains "true" or a file path instead of hex, your script logic is overwriting the secret.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Layer 2: L1 Connectivity&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;The &lt;code&gt;op-node&lt;/code&gt; derives L2 blocks from L1 data. If L1 is down, L2 stops.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check:&lt;/strong&gt; &lt;code&gt;docker exec node-1 curl -s http://testnet:8585&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Ensure your L1 node is fully synced and reachable on the Docker network.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Layer 3: P2P Peering&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;In &lt;code&gt;snap&lt;/code&gt; sync mode, you need peers to download the state.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check:&lt;/strong&gt; &lt;code&gt;curl -s -d '{"id":1,"jsonrpc":"2.0","method":"opp2p_peerStats"}' http://localhost:9645 | jq&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; Ensure ports &lt;code&gt;30303&lt;/code&gt; (Geth) and &lt;code&gt;9222&lt;/code&gt; (Node) are open on your firewall.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  5. Monitoring Your Progress
&lt;/h3&gt;

&lt;p&gt;The most important command for a node operator is the &lt;strong&gt;Sync Status&lt;/strong&gt;. It tells you exactly where you are in history.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "Scoreboard" Command:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;watch &lt;span class="nt"&gt;-n&lt;/span&gt; 5 &lt;span class="s1"&gt;'curl -s -X POST -H "Content-Type: application/json" --data "{\"jsonrpc\":\"2.0\",\"method\":\"optimism_syncStatus\",\"params\":[],\"id\":1}" http://localhost:9645 | jq ".result.local_safe_l2.number, .result.local_safe_l2.timestamp"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First Number:&lt;/strong&gt; Current L2 Block Height.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Second Number:&lt;/strong&gt; Unix Timestamp of that block (compare this to the current time to see how many days/months you are behind).&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Summary
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Generate&lt;/strong&gt; a clean JWT.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Align&lt;/strong&gt; your volumes so both containers see the same &lt;code&gt;/shared/jwt.txt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Point&lt;/strong&gt; to a healthy L1 RPC and Beacon node.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Monitor&lt;/strong&gt; via &lt;code&gt;optimism_syncStatus&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>base</category>
      <category>sepolia</category>
      <category>docker</category>
      <category>kvm</category>
    </item>
    <item>
      <title>Automating Container Image Updates with FluxCD (Hands-On Tutorial)</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Sat, 21 Mar 2026 05:06:50 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/automating-container-image-updates-with-fluxcd-hands-on-tutorial-88k</link>
      <guid>https://dev.to/pilgrim2go/automating-container-image-updates-with-fluxcd-hands-on-tutorial-88k</guid>
      <description>&lt;p&gt;Modern GitOps workflows aim to keep your Kubernetes cluster fully synchronized with what is defined in Git. One powerful feature of Flux is &lt;strong&gt;Image Automation&lt;/strong&gt;, which automatically updates container image tags in your Git repository whenever a new image becomes available.&lt;/p&gt;

&lt;p&gt;In this tutorial, we walk through how image automation works and how to troubleshoot a common issue involving Git authentication.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. What is Flux Image Automation?
&lt;/h2&gt;

&lt;p&gt;FluxCD provides several components that work together to automate image updates.&lt;/p&gt;

&lt;p&gt;The workflow looks like this:&lt;/p&gt;

&lt;p&gt;Container Registry&lt;br&gt;
↓&lt;br&gt;
ImageRepository (scans tags)&lt;br&gt;
↓&lt;br&gt;
ImagePolicy (selects allowed tags)&lt;br&gt;
↓&lt;br&gt;
ImageUpdateAutomation (commits updates to Git)&lt;br&gt;
↓&lt;br&gt;
Flux Kustomization deploys updated workload&lt;/p&gt;

&lt;p&gt;Instead of manually updating image tags in Git, Flux automatically commits the new tag when a matching image appears in your container registry.&lt;/p&gt;


&lt;h2&gt;
  
  
  2. Repository Structure Used in This Lab
&lt;/h2&gt;

&lt;p&gt;Example GitOps layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flux-minikube-lab
├── apps
│   └── web-server
│       ├── web-server.yaml
│       ├── sealed-db-pass.yaml
│       └── kustomization.yaml
│
└── clusters
    └── my-cluster
        ├── image-reflect.yaml
        ├── image-policy.yaml
        ├── web-server-sync.yaml
        └── kustomization.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cluster Kustomization references infrastructure and application sync resources.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Application Deployment
&lt;/h2&gt;

&lt;p&gt;The web server manifests are defined in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apps/web-server/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example &lt;code&gt;kustomization.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;web-server.yaml&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sealed-db-pass.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Flux Kustomization sync object deploys the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kustomization&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web-server-sync&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flux-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./apps/web-server&lt;/span&gt;
  &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;sourceRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GitRepository&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flux-system&lt;/span&gt;
  &lt;span class="na"&gt;targetNamespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;engineering&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This instructs Flux to deploy the web server into the &lt;code&gt;engineering&lt;/code&gt; namespace.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Image Automation Setup
&lt;/h2&gt;

&lt;p&gt;Three resources enable automated updates:&lt;/p&gt;

&lt;h3&gt;
  
  
  Image Repository
&lt;/h3&gt;

&lt;p&gt;This scans container registry tags.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ImageRepository&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Image Policy
&lt;/h3&gt;

&lt;p&gt;Defines which tags are acceptable (e.g., semver or latest).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ImagePolicy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Image Update Automation
&lt;/h3&gt;

&lt;p&gt;Updates the Git repo when a new tag matches the policy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ImageUpdateAutomation&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. Triggering Automation
&lt;/h2&gt;

&lt;p&gt;To manually trigger automation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;flux reconcile image update flux-system
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flux will:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check registry tags&lt;/li&gt;
&lt;li&gt;Apply the image policy&lt;/li&gt;
&lt;li&gt;Update manifests in Git&lt;/li&gt;
&lt;li&gt;Commit and push the change&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  6. Troubleshooting a Common Error
&lt;/h2&gt;

&lt;p&gt;While testing automation, the following error appeared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;failed to update source:
failed to push to remote:
ERROR: The key you are authenticating with has been marked as read only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking the automation status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;flux get image update
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;READY: False
MESSAGE: failed to push to remote
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  7. Root Cause
&lt;/h2&gt;

&lt;p&gt;ImageUpdateAutomation needs &lt;strong&gt;write access to the Git repository&lt;/strong&gt; to commit updated image tags.&lt;/p&gt;

&lt;p&gt;However, the repository authentication key was configured as &lt;strong&gt;read-only&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This allowed Flux to pull manifests but prevented it from pushing updates.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Fix: Recreate the Flux Git Secret with Write Access
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1 — Generate a New SSH Key
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;ssh-keygen
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Step 2 — Add the Public Key to GitHub
&lt;/h3&gt;

&lt;p&gt;Navigate to the repository settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Settings → Deploy Keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the new public key and enable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Allow write access
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Step 3 — Recreate the Flux Git Secret
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;flux create secret git flux-system \
  --url=ssh://git@github.com/pilgrim2go/flux-minikube-lab \
&lt;/span&gt;&lt;span class="gp"&gt;  --private-key-file=$&lt;/span&gt;PWD/fluxcd-test &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="go"&gt;  -n flux-system
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This updates the authentication credentials used by Flux.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 4 — Re-run Automation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;flux reconcile image update flux-system
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;flux get image update
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;READY: True
MESSAGE: committed and pushed update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new commit should appear in the repository with the updated image tag.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Helpful Debug Commands
&lt;/h2&gt;

&lt;p&gt;Check Flux resources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;flux get all
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inspect automation status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;flux get image update
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;View controller logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;kubectl logs -n flux-system deploy/image-automation-controller
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trigger Git sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;flux reconcile source git flux-system
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  10. Best Practices
&lt;/h2&gt;

&lt;p&gt;When using Flux image automation:&lt;/p&gt;

&lt;p&gt;• Use a dedicated deploy key or bot account&lt;br&gt;
• Ensure Git credentials allow &lt;strong&gt;read and write access&lt;/strong&gt;&lt;br&gt;
• Keep Git as the &lt;strong&gt;single source of truth&lt;/strong&gt;&lt;br&gt;
• Avoid manual cluster changes outside GitOps&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Flux image automation eliminates manual image updates and ensures that your Kubernetes workloads always run the latest approved container images.&lt;/p&gt;

&lt;p&gt;With the proper Git authentication setup, Flux can automatically:&lt;/p&gt;

&lt;p&gt;• Detect new container images&lt;br&gt;
• Update manifests in Git&lt;br&gt;
• Trigger GitOps deployments&lt;/p&gt;

&lt;p&gt;This enables a fully automated and auditable Kubernetes delivery pipeline.&lt;/p&gt;

</description>
      <category>fluxcd</category>
      <category>cicd</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>FluxCD Image Automation Error Troubleshooting</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Sat, 21 Mar 2026 05:05:02 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/fluxcd-image-automation-error-troubleshooting-1el2</link>
      <guid>https://dev.to/pilgrim2go/fluxcd-image-automation-error-troubleshooting-1el2</guid>
      <description>&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;Running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flux reconcile image update flux-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;failed to update source: failed to push to remote
ERROR: The key you are authenticating with has been marked as read only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flux get image update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;READY: False
MESSAGE: failed to push to remote
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Root Cause
&lt;/h2&gt;

&lt;p&gt;FluxCD &lt;strong&gt;ImageUpdateAutomation&lt;/strong&gt; needs to &lt;strong&gt;commit and push updates to the Git repository&lt;/strong&gt; when it updates container image tags.&lt;/p&gt;

&lt;p&gt;Pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Container Registry
        ↓
ImageRepository
        ↓
ImagePolicy
        ↓
ImageUpdateAutomation
        ↓
Git Commit + Push
        ↓
Flux Kustomization deploys update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the Git credential is &lt;strong&gt;read-only&lt;/strong&gt;, the push fails.&lt;/p&gt;




&lt;h2&gt;
  
  
  Diagnosis Steps
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Check Image Automation Status
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flux get image update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;READY: False
failed to push to remote
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  2. Inspect the Automation Object
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get imageupdateautomation &lt;span class="nt"&gt;-A&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STATUS: failed to update source
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3. Check the Git Source
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flux get sources git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This confirms Flux can &lt;strong&gt;read&lt;/strong&gt; the repo.&lt;/p&gt;

&lt;p&gt;But pushing still fails if the key is read-only.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. Confirm Git Authentication
&lt;/h3&gt;

&lt;p&gt;Check the Git secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get secret flux-system &lt;span class="nt"&gt;-n&lt;/span&gt; flux-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This secret contains the SSH key Flux uses.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fix Implemented
&lt;/h2&gt;

&lt;p&gt;You recreated the Git authentication secret with a &lt;strong&gt;write-enabled SSH key&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  1️⃣ Generate SSH Key
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  2️⃣ Add Public Key to GitHub
&lt;/h3&gt;

&lt;p&gt;Go to repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Settings → Deploy Keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fluxcd-test.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Allow write access
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3️⃣ Recreate Flux Git Secret
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flux create secret git flux-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ssh://git@github.com/pilgrim2go/flux-minikube-lab &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--private-key-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$PWD&lt;/span&gt;/fluxcd-test &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; flux-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This updates the Git credential used by Flux.&lt;/p&gt;




&lt;h3&gt;
  
  
  4️⃣ Trigger Automation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flux reconcile image update flux-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Expected Result
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flux get image update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;READY: True
MESSAGE: committed and pushed update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should also see a commit in Git like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flux: update image tag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Useful Debug Commands
&lt;/h2&gt;

&lt;p&gt;Check full Flux status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flux get all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check automation logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl logs &lt;span class="nt"&gt;-n&lt;/span&gt; flux-system deploy/image-automation-controller
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test Git sync:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flux reconcile &lt;span class="nb"&gt;source &lt;/span&gt;git flux-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Best Practice
&lt;/h2&gt;

&lt;p&gt;Use a &lt;strong&gt;dedicated Flux deploy key&lt;/strong&gt; with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;read + write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;instead of personal access tokens when using SSH Git repositories.&lt;/p&gt;

</description>
      <category>fluxcd</category>
      <category>minikube</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>FluxCD journey with Minikube</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Fri, 20 Mar 2026 14:33:52 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/fluxcd-journey-with-minikube-16pg</link>
      <guid>https://dev.to/pilgrim2go/fluxcd-journey-with-minikube-16pg</guid>
      <description>&lt;h2&gt;
  
  
  🚀 Phase 1: The Manual Foundation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; Set up the cluster and deploy a "Hello World" app the old-fashioned way to understand what we are automating.&lt;/p&gt;

&lt;h3&gt;
  
  
  🛠️ Step 1: Install Tools
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install the Big Three (macOS example)&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;minikube kubectl fluxcd/tap/flux

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🏗️ Step 2: Start Minikube
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;minikube start &lt;span class="nt"&gt;--cpus&lt;/span&gt; 2 &lt;span class="nt"&gt;--memory&lt;/span&gt; 4096 &lt;span class="nt"&gt;--driver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;docker
minikube addons &lt;span class="nb"&gt;enable &lt;/span&gt;ingress

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  📂 Step 3: Directory Layout
&lt;/h3&gt;

&lt;p&gt;Create this structure on your local machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flux-lab/
└── base/
    ├── kustomization.yaml
    └── web-server.yaml

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  📄 Step 4: The Manifests
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;flux-lab/base/web-server.yaml&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web-server&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;engineering&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.25&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;flux-lab/base/kustomization.yaml&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;web-server.yaml&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🚀 Step 5: Deploy Manually
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace engineering
kubectl apply &lt;span class="nt"&gt;-k&lt;/span&gt; flux-lab/base/
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; engineering

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🤖 Phase 2: The Great Automation (FluxCD)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; Connect GitHub to Minikube. From this point on, we never use &lt;code&gt;kubectl apply&lt;/code&gt; again.&lt;/p&gt;

&lt;h3&gt;
  
  
  🛠️ Step 1: Environment Setup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_personal_access_token
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GITHUB_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your_github_username

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🏗️ Step 2: Bootstrap Flux
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flux bootstrap github &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--owner&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_USER&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--repository&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;flux-minikube-lab &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--branch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;main &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;clusters/my-cluster &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--personal&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  📂 Step 3: Final Git Directory Layout
&lt;/h3&gt;

&lt;p&gt;Clone your new repo and organize it exactly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flux-minikube-lab/
├── apps/
│   └── web-server/
│       ├── kustomization.yaml
│       └── web-server.yaml
└── clusters/
    └── my-cluster/
        ├── flux-system/         # (Auto-generated)
        └── web-server-sync.yaml

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  📄 Step 4: Create the "Sync" Instruction
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;clusters/my-cluster/web-server-sync.yaml&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize.toolkit.fluxcd.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kustomization&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;web-server-sync&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flux-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./apps/web-server&lt;/span&gt;
  &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;sourceRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GitRepository&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flux-system&lt;/span&gt;
  &lt;span class="na"&gt;targetNamespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;engineering&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🚀 Step 5: Push and Pray (The GitOps Way)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Onboard web-server to GitOps"&lt;/span&gt;
git push origin main

&lt;span class="c"&gt;# Force immediate sync&lt;/span&gt;
flux reconcile kustomization flux-system &lt;span class="nt"&gt;--with-source&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🔐 Phase 3: The Secret Sauce (Sealed Secrets)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Goal:&lt;/strong&gt; Store passwords in GitHub securely using encryption.&lt;/p&gt;

&lt;h3&gt;
  
  
  🏗️ Step 1: Install Infrastructure
&lt;/h3&gt;

&lt;p&gt;Place these files in &lt;code&gt;infrastructure/sources/&lt;/code&gt; and &lt;code&gt;infrastructure/controllers/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;clusters/my-cluster/infra-sync.yaml&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kustomize.toolkit.fluxcd.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Kustomization&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;infra-sync&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flux-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./infrastructure&lt;/span&gt;
  &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;sourceRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GitRepository&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;flux-system&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  📂 Step 2: Final Phase 3 Directory Layout
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flux-minikube-lab/
├── apps/
│   └── web-server/
│       ├── kustomization.yaml   # (Update to include sealed-db-pass.yaml)
│       ├── web-server.yaml
│       └── sealed-db-pass.yaml  # (Generated)
├── clusters/
│   └── my-cluster/
│       ├── infra-sync.yaml
│       └── web-server-sync.yaml
└── infrastructure/
    ├── controllers/
    │   └── sealed-secrets.yaml
    └── sources/
        └── sealed-secrets.yaml

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🔐 Step 3: Create an Encrypted Secret
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Create a raw secret (DO NOT PUSH TO GIT)&lt;/span&gt;
kubectl create secret generic mwd-db-pass &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;SuperSecret123 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; engineering &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dry-run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client &lt;span class="nt"&gt;-o&lt;/span&gt; yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; temp.yaml

&lt;span class="c"&gt;# 2. Encrypt it using the cluster's key&lt;/span&gt;
kubeseal &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--controller-name&lt;/span&gt; sealed-secrets &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--controller-namespace&lt;/span&gt; flux-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; yaml &amp;lt; temp.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; apps/web-server/sealed-db-pass.yaml

&lt;span class="c"&gt;# 3. Clean up&lt;/span&gt;
&lt;span class="nb"&gt;rm &lt;/span&gt;temp.yaml

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  🚀 Step 4: Deploy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add &lt;span class="nb"&gt;.&lt;/span&gt;
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Add sealed secret"&lt;/span&gt;
git push origin main
flux reconcile kustomization infra-sync &lt;span class="nt"&gt;--with-source&lt;/span&gt;
flux reconcile kustomization web-server-sync &lt;span class="nt"&gt;--with-source&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  🧠 Summary of Progress
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1:&lt;/strong&gt; Learned &lt;strong&gt;Kubernetes&lt;/strong&gt; resources.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 2:&lt;/strong&gt; Learned &lt;strong&gt;FluxCD&lt;/strong&gt; automation and the "Pull Model."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 3:&lt;/strong&gt; Learned &lt;strong&gt;Security&lt;/strong&gt; and encryption in Git.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>fluxcd</category>
      <category>minikube</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>🧠 Migrating BSC Testnet Data to a New Disk (KVM + Docker)</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Thu, 19 Mar 2026 03:44:50 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/migrating-bsc-testnet-data-to-a-new-disk-kvm-docker-4gf9</link>
      <guid>https://dev.to/pilgrim2go/migrating-bsc-testnet-data-to-a-new-disk-kvm-docker-4gf9</guid>
      <description>&lt;p&gt;Running blockchain nodes at scale quickly turns into a &lt;strong&gt;storage management problem&lt;/strong&gt;.&lt;br&gt;
In this guide, I’ll walk through a real-world migration of a &lt;strong&gt;BSC testnet dataset (~243GB)&lt;/strong&gt; to a new disk — without breaking the node — and share key lessons learned.&lt;/p&gt;
&lt;h2&gt;
  
  
  📊 Initial Situation
&lt;/h2&gt;

&lt;p&gt;Inside the VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/dev/vdb1 → /node-data (6.3T disk, almost full)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breakdown:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mainnet: ~5.8TB&lt;/li&gt;
&lt;li&gt;Testnet: ~243GB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Disk usage: 100%
Free space: ~41GB ⚠️
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;👉 Risk: node crash, DB corruption, sync failure&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 Goal
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Move &lt;strong&gt;testnet data&lt;/strong&gt; to a new disk&lt;/li&gt;
&lt;li&gt;Keep &lt;strong&gt;mainnet untouched&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Avoid complex Docker changes&lt;/li&gt;
&lt;li&gt;Minimize downtime&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🏗️ Architecture Before
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vdb (6.3T)
└── /node-data
    ├── mainnet (~5.8T)
    └── testnet (~243G)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🚀 Architecture After
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vdb → mainnet
vdc → testnet

/bsc-testnet → real mount
/node-data/testnet → bind mount → /bsc-testnet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker still uses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/node-data/testnet:/bsc/node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;👉 No container config change needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚡ Step-by-Step Migration
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1️⃣ Attach new disk (on host)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qemu-img create &lt;span class="nt"&gt;-f&lt;/span&gt; qcow2 /path/bsc-testnet.qcow2 400G

virsh attach-disk bsc &lt;span class="se"&gt;\&lt;/span&gt;
  /path/bsc-testnet.qcow2 &lt;span class="se"&gt;\&lt;/span&gt;
  vdc &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--targetbus&lt;/span&gt; virtio &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subdriver&lt;/span&gt; qcow2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--persistent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  2️⃣ Prepare disk (inside VM)
&lt;/h3&gt;

&lt;p&gt;Skip partitioning (simpler):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mkfs.ext4 /dev/vdc
&lt;span class="nb"&gt;mkdir&lt;/span&gt; /bsc-testnet
mount /dev/vdc /bsc-testnet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  3️⃣ Copy data
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rsync &lt;span class="nt"&gt;-avh&lt;/span&gt; /node-data/testnet/ /bsc-testnet/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  4️⃣ Switch using bind mount (no Docker change)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker stop testnet

&lt;span class="nb"&gt;mv&lt;/span&gt; /node-data/testnet /node-data/testnet-old
&lt;span class="nb"&gt;mkdir&lt;/span&gt; /node-data/testnet

mount &lt;span class="nt"&gt;--bind&lt;/span&gt; /bsc-testnet /node-data/testnet

docker start testnet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  5️⃣ Cleanup (after verification)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /node-data/testnet-old
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  6️⃣ Make persistent
&lt;/h3&gt;

&lt;p&gt;Edit &lt;code&gt;/etc/fstab&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;UUID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;vdc-uuid&amp;gt; /bsc-testnet ext4 defaults 0 0
/bsc-testnet /node-data/testnet none &lt;span class="nb"&gt;bind &lt;/span&gt;0 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mount &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🔥 Key Lessons Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1️⃣ You don’t need to touch Docker
&lt;/h3&gt;

&lt;p&gt;Instead of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;editing volumes&lt;/li&gt;
&lt;li&gt;recreating containers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;👉 Just move the &lt;strong&gt;filesystem underneath&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is safer and faster.&lt;/p&gt;




&lt;h3&gt;
  
  
  2️⃣ Bind mounts are extremely powerful
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/node-data/testnet → /bsc-testnet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Acts like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;transparent redirect&lt;/li&gt;
&lt;li&gt;zero config change&lt;/li&gt;
&lt;li&gt;instant rollback&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  3️⃣ “target is busy” = you are inside the directory
&lt;/h3&gt;

&lt;p&gt;Classic mistake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;umount /node-data/testnet
→ target is busy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cwd &lt;span class="o"&gt;=&lt;/span&gt; /node-data/testnet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  4️⃣ Duplicate mounts can happen easily
&lt;/h3&gt;

&lt;p&gt;Running &lt;code&gt;mount --bind&lt;/code&gt; multiple times creates &lt;strong&gt;stacked mounts&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Check with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mount | &lt;span class="nb"&gt;grep &lt;/span&gt;testnet
findmnt /node-data/testnet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;umount /node-data/testnet &lt;span class="o"&gt;(&lt;/span&gt;repeat &lt;span class="k"&gt;until &lt;/span&gt;gone&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  5️⃣ Never unmount while container is running
&lt;/h3&gt;

&lt;p&gt;Even if it “works”:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DB writes can fail&lt;/li&gt;
&lt;li&gt;corruption risk&lt;/li&gt;
&lt;li&gt;node may resync from scratch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;👉 Always:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker stop → umount → remount → start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  6️⃣ Partitioning is optional
&lt;/h3&gt;

&lt;p&gt;For dedicated disks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mkfs.ext4 /dev/vdc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is simpler than:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fdisk → /dev/vdc1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  7️⃣ Blockchain nodes will ALWAYS outgrow your disk
&lt;/h3&gt;

&lt;p&gt;After migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mainnet still ~92% full
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;👉 This is temporary relief.&lt;/p&gt;

&lt;p&gt;You must plan:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;disk expansion (&lt;code&gt;qemu-img resize&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;or data rebalancing&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  📈 Results
&lt;/h2&gt;

&lt;p&gt;After migration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Freed ~243GB on main disk&lt;/li&gt;
&lt;li&gt;Isolated IO between mainnet/testnet&lt;/li&gt;
&lt;li&gt;No Docker reconfiguration&lt;/li&gt;
&lt;li&gt;Minimal downtime (~1–2 minutes)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🧠 Final Takeaway
&lt;/h2&gt;

&lt;p&gt;The key mindset shift:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Don’t move applications — move the filesystem underneath them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reduces risk&lt;/li&gt;
&lt;li&gt;simplifies operations&lt;/li&gt;
&lt;li&gt;scales better for large datasets (TBs)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  🚀 What’s Next
&lt;/h2&gt;

&lt;p&gt;For production-grade setups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Separate disks per chain&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;virsh blockcopy&lt;/code&gt; for live migration&lt;/li&gt;
&lt;li&gt;Implement storage balancing strategy across NVMe pools&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>bsc</category>
      <category>kvm</category>
      <category>blockchain</category>
      <category>docker</category>
    </item>
    <item>
      <title>vLLM Request Lifecycle (Where TTFT is measured)</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Wed, 11 Mar 2026 13:12:54 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/vllm-request-lifecycle-where-ttft-is-measured-5cca</link>
      <guid>https://dev.to/pilgrim2go/vllm-request-lifecycle-where-ttft-is-measured-5cca</guid>
      <description>&lt;h2&gt;
  
  
  Observing LLM Latency: Monitoring Time-To-First-Token in vLLM
&lt;/h2&gt;

&lt;p&gt;Large language model APIs often feel fast even when generating long responses.&lt;br&gt;
The key reason is &lt;strong&gt;streaming tokens&lt;/strong&gt; — the response starts quickly while the rest of the answer is still being generated.&lt;/p&gt;

&lt;p&gt;The most important metric for this experience is &lt;strong&gt;Time-To-First-Token (TTFT)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This tutorial explains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what TTFT is&lt;/li&gt;
&lt;li&gt;how &lt;strong&gt;vLLM exposes TTFT metrics&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;how &lt;strong&gt;Prometheus calculates percentiles&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;how to build a &lt;strong&gt;Grafana dashboard&lt;/strong&gt;
&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm435scmyo0g3vsfiif5m.png" alt="request lifecycle" width="598" height="880"&gt;
&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  What is Time-To-First-Token (TTFT)?
&lt;/h2&gt;

&lt;p&gt;TTFT measures how long it takes from &lt;strong&gt;request arrival to the first generated token&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;LLM inference pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request arrives
      ↓
Prompt tokenization
      ↓
Prefill (model processes prompt)
      ↓
FIRST TOKEN GENERATED  ← TTFT measured here
      ↓
Token streaming
      ↓
Response complete
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users perceive the system as &lt;strong&gt;fast&lt;/strong&gt; if TTFT is small.&lt;/p&gt;

&lt;p&gt;Typical targets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Good TTFT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chat UI&lt;/td&gt;
&lt;td&gt;&amp;lt;2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU inference&lt;/td&gt;
&lt;td&gt;&amp;lt;4s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heavy batching&lt;/td&gt;
&lt;td&gt;&amp;lt;8s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  vLLM TTFT Metric
&lt;/h2&gt;

&lt;p&gt;vLLM exports a &lt;strong&gt;Prometheus histogram metric&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;vllm:time_to_first_token_seconds_bucket&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example metrics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="n"&gt;vllm:time_to_first_token_seconds_bucket&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="n"&gt;vllm:time_to_first_token_seconds_bucket&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"2.5"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mi"&gt;73&lt;/span&gt;
&lt;span class="n"&gt;vllm:time_to_first_token_seconds_bucket&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"10"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mi"&gt;103&lt;/span&gt;
&lt;span class="n"&gt;vllm:time_to_first_token_seconds_bucket&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"20"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="mi"&gt;104&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These represent &lt;strong&gt;histogram buckets&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Example interpretation:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;TTFT&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;≤1s&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;≤2.5s&lt;/td&gt;
&lt;td&gt;73&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;≤10s&lt;/td&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;≤20s&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Buckets are &lt;strong&gt;cumulative counters&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Causes of High TTFT
&lt;/h2&gt;

&lt;p&gt;If TTFT increases, check:&lt;/p&gt;

&lt;h3&gt;
  
  
  GPU saturation
&lt;/h3&gt;

&lt;p&gt;Too many requests in queue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Large prompts
&lt;/h3&gt;

&lt;p&gt;Prefill phase takes longer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batch scheduling delay
&lt;/h3&gt;

&lt;p&gt;Large batch sizes increase wait time.&lt;/p&gt;

&lt;h3&gt;
  
  
  KV cache limits
&lt;/h3&gt;

&lt;p&gt;Cache misses slow inference.&lt;/p&gt;




&lt;h2&gt;
  
  
  Recommended vLLM Observability Metrics
&lt;/h2&gt;

&lt;p&gt;Monitor these together:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TTFT&lt;/td&gt;
&lt;td&gt;time until response starts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tokens/sec&lt;/td&gt;
&lt;td&gt;generation speed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;request latency&lt;/td&gt;
&lt;td&gt;full response time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;active requests&lt;/td&gt;
&lt;td&gt;queue pressure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU utilization&lt;/td&gt;
&lt;td&gt;hardware bottleneck&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Monitoring TTFT helps maintain a responsive LLM system.&lt;/p&gt;

&lt;p&gt;Pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vLLM metrics
     ↓
Prometheus histogram
     ↓
PromQL percentile query
     ↓
Grafana visualization
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With proper monitoring, you can detect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GPU saturation&lt;/li&gt;
&lt;li&gt;batching issues&lt;/li&gt;
&lt;li&gt;prompt bottlenecks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;before users notice degraded performance.&lt;/p&gt;

</description>
      <category>vllm</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Troubleshooting: Fixing k6 GPG Key Errors in Ubuntu</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Wed, 11 Mar 2026 03:41:07 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/troubleshooting-fixing-k6-gpg-key-errors-in-ubuntu-44hg</link>
      <guid>https://dev.to/pilgrim2go/troubleshooting-fixing-k6-gpg-key-errors-in-ubuntu-44hg</guid>
      <description>&lt;p&gt;Setting up k6 on Ubuntu should be a straightforward process, but GPG key errors can occasionally turn a "five-minute task" into a troubleshooting marathon. If you’ve encountered the dreaded &lt;code&gt;NO_PUBKEY&lt;/code&gt; or &lt;code&gt;unsupported filetype&lt;/code&gt; errors, this guide will walk you through the fix and the proper installation.&lt;/p&gt;

&lt;p&gt;Modern Linux distributions have moved away from &lt;code&gt;apt-key add&lt;/code&gt; (which is now deprecated) toward specific keyrings located in &lt;code&gt;/usr/share/keyrings&lt;/code&gt;. This "sandboxes" the security keys so that the k6 key can only be used to verify k6 packages, making your overall system much more secure.&lt;/p&gt;




&lt;h2&gt;
  
  
  🛠️ Troubleshooting: Fixing k6 GPG Key Errors
&lt;/h2&gt;

&lt;p&gt;When you see errors like &lt;strong&gt;"The following signatures couldn't be verified"&lt;/strong&gt; or &lt;strong&gt;"unsupported filetype,"&lt;/strong&gt; it means your system doesn't trust the k6 repository or the key file you downloaded is corrupted (usually saved as plain text instead of binary).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Clean Up Old Keys
&lt;/h3&gt;

&lt;p&gt;If you have a broken keyring file, &lt;code&gt;apt&lt;/code&gt; will continue to complain until it's gone. Start by removing any existing k6 keyring:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo rm&lt;/span&gt; /usr/share/keyrings/k6-archive-keyring.gpg

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Download and "Dearmor" the Key
&lt;/h3&gt;

&lt;p&gt;The key must be in a binary format for modern Ubuntu versions. We use &lt;code&gt;gpg --dearmor&lt;/code&gt; to convert the text-based key from k6 into the required binary format.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://dl.k6.io/key.gpg | &lt;span class="nb"&gt;sudo &lt;/span&gt;gpg &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/share/keyrings/k6-archive-keyring.gpg

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Set Correct Permissions
&lt;/h3&gt;

&lt;p&gt;System services need to be able to read this file. Set the permissions to &lt;strong&gt;644&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;644 /usr/share/keyrings/k6-archive-keyring.gpg

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  📦 Setting Up the Repository
&lt;/h2&gt;

&lt;p&gt;Now that the security "handshake" is ready, you need to tell Ubuntu exactly where to find k6 and which key to use for verification.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Add the k6 Source List
&lt;/h3&gt;

&lt;p&gt;Run this command to create (or overwrite) the source file. Note the &lt;code&gt;signed-by&lt;/code&gt; tag—this is the secret sauce that links the repo to the key we just created.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/k6.list

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🚀 Final Installation
&lt;/h2&gt;

&lt;p&gt;With the repository correctly configured and the key verified, you can now update your package list and install k6.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Update and Install
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install &lt;/span&gt;k6

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 6: Verify Success
&lt;/h3&gt;

&lt;p&gt;Confirm k6 is installed and ready to go by checking the version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;k6 version

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>k6</category>
      <category>locust</category>
      <category>hey</category>
      <category>ubuntu</category>
    </item>
    <item>
      <title>Overlay vs Underlay Networking Across Regions with Azure Kubernetes Service</title>
      <dc:creator>iapilgrim</dc:creator>
      <pubDate>Mon, 09 Mar 2026 03:17:28 +0000</pubDate>
      <link>https://dev.to/pilgrim2go/overlay-vs-underlay-networking-across-regions-with-azure-kubernetes-service-266o</link>
      <guid>https://dev.to/pilgrim2go/overlay-vs-underlay-networking-across-regions-with-azure-kubernetes-service-266o</guid>
      <description>&lt;p&gt;In this tutorial we build a &lt;strong&gt;complete AKS networking lab&lt;/strong&gt; to demonstrate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AKS &lt;strong&gt;Overlay networking&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;AKS &lt;strong&gt;Underlay (Azure CNI) networking&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-region VNet peering&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pod-to-pod communication across clusters&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Access to Azure SQL via Private Endpoint&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Service exposure via NodePort&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Final architecture:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftxt30ld0j5kw793hunh5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftxt30ld0j5kw793hunh5.png" alt="Network Design" width="683" height="481"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Region 1 (Southeast Asia)
VNet: 10.0.0.0/16
--------------------------------

AKS Overlay Cluster
Nodes: 10.0.x.x
Pods : 192.168.x.x

Private Endpoint
Azure SQL
10.0.4.4

Private DNS Zone
privatelink.database.windows.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Region 2 (East Asia)
VNet: 10.1.0.0/16
--------------------------------

AKS Underlay Cluster
Nodes: 10.1.x.x
Pods : 10.1.x.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VNet Peering
10.0.0.0/16  &amp;lt;----&amp;gt;  10.1.0.0/16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  1. Create the Overlay AKS Cluster
&lt;/h2&gt;

&lt;p&gt;Overlay networking allows pods to use &lt;strong&gt;non-VNet IP ranges&lt;/strong&gt;, which prevents subnet exhaustion.&lt;/p&gt;

&lt;h3&gt;
  
  
  Create resource group
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;RG_NETWORK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rg-aks-network
&lt;span class="nv"&gt;LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;southeastasia

az group create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOCATION&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Create VNet
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VNET_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vnet-aks-overlay

az network vnet create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$VNET_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--address-prefix&lt;/span&gt; 10.0.0.0/16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet-name&lt;/span&gt; aks-subnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet-prefix&lt;/span&gt; 10.0.1.0/24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Create AKS overlay cluster
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;AKS_OVERLAY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aks-overlay

&lt;span class="nv"&gt;SUBNET_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az network vnet subnet show &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; &lt;span class="nv"&gt;$VNET_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; aks-subnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;

az aks create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$AKS_OVERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOCATION&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-count&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network-plugin&lt;/span&gt; azure &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network-plugin-mode&lt;/span&gt; overlay &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--pod-cidr&lt;/span&gt; 192.168.0.0/16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnet-subnet-id&lt;/span&gt; &lt;span class="nv"&gt;$SUBNET_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--generate-ssh-keys&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Connect kubectl
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks get-credentials &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$AKS_OVERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--context&lt;/span&gt; aks-overlay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Verify overlay pod networking
&lt;/h3&gt;

&lt;p&gt;Deploy a test pod.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl run overlay-test &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;3600
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check pod IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;overlay-test   192.168.0.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Overlay pods use &lt;strong&gt;192.168.x.x&lt;/strong&gt;, not VNet IPs.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Create Azure SQL with Private Endpoint
&lt;/h2&gt;

&lt;p&gt;Create SQL server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SQL_SERVER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;aks-lab-sql-31445

az sql server create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$SQL_SERVER&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$LOCATION&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--admin-user&lt;/span&gt; sqladmin &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--admin-password&lt;/span&gt; &amp;lt;password&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Create private endpoint
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network private-endpoint create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; sql-pe &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; &lt;span class="nv"&gt;$VNET_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet&lt;/span&gt; aks-subnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--private-connection-resource-id&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;az sql server show &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$SQL_SERVER&lt;/span&gt; &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt; &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--group-id&lt;/span&gt; sqlServer &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--connection-name&lt;/span&gt; sql-pe-connection
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Create Private DNS zone
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network private-dns zone create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; privatelink.database.windows.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Link overlay VNet
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network private-dns &lt;span class="nb"&gt;link &lt;/span&gt;vnet create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone-name&lt;/span&gt; privatelink.database.windows.net &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; link-overlay-vnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--virtual-network&lt;/span&gt; &lt;span class="nv"&gt;$VNET_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--registration-enabled&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Test SQL access from overlay pod
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; overlay-test &lt;span class="nt"&gt;--&lt;/span&gt; sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nslookup &amp;lt;sql-server&amp;gt;.database.windows.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.0.x.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  3. Create Underlay AKS Cluster in Another Region
&lt;/h2&gt;

&lt;p&gt;Underlay clusters assign &lt;strong&gt;real VNet IPs to pods&lt;/strong&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Create resource group
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;RG_UNDERLAY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;rg-aks-underlay
&lt;span class="nv"&gt;UNDERLAY_LOCATION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;eastasia

az group create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$RG_UNDERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$UNDERLAY_LOCATION&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Create VNet
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VNET_UNDERLAY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vnet-aks-underlay

az network vnet create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_UNDERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="nv"&gt;$VNET_UNDERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$UNDERLAY_LOCATION&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--address-prefix&lt;/span&gt; 10.1.0.0/16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet-name&lt;/span&gt; aks-subnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--subnet-prefix&lt;/span&gt; 10.1.1.0/24
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Create underlay AKS cluster
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SUBNET_UNDERLAY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;az network vnet subnet show &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_UNDERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; &lt;span class="nv"&gt;$VNET_UNDERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; aks-subnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv&lt;span class="si"&gt;)&lt;/span&gt;

az aks create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_UNDERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; aks-underlay &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--location&lt;/span&gt; &lt;span class="nv"&gt;$UNDERLAY_LOCATION&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--node-count&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--network-plugin&lt;/span&gt; azure &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnet-subnet-id&lt;/span&gt; &lt;span class="nv"&gt;$SUBNET_UNDERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--service-cidr&lt;/span&gt; 172.17.0.0/16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dns-service-ip&lt;/span&gt; 172.17.0.10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--generate-ssh-keys&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Connect kubectl
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az aks get-credentials &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_UNDERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; aks-underlay &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--context&lt;/span&gt; aks-underlay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Verify pod IP
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;--context&lt;/span&gt; aks-underlay run underlay-test &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;3600
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.1.x.x
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pods now consume &lt;strong&gt;VNet IPs&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Peer the VNets
&lt;/h2&gt;

&lt;p&gt;Overlay and underlay clusters must communicate.&lt;/p&gt;




&lt;h3&gt;
  
  
  Overlay → Underlay
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network vnet peering create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; overlay-to-underlay &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; &lt;span class="nv"&gt;$VNET_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--remote-vnet&lt;/span&gt; /subscriptions/&amp;lt;sub&amp;gt;/resourceGroups/&lt;span class="nv"&gt;$RG_UNDERLAY&lt;/span&gt;/providers/Microsoft.Network/virtualNetworks/&lt;span class="nv"&gt;$VNET_UNDERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-vnet-access&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Underlay → Overlay
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network vnet peering create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_UNDERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; underlay-to-overlay &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--vnet-name&lt;/span&gt; &lt;span class="nv"&gt;$VNET_UNDERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--remote-vnet&lt;/span&gt; /subscriptions/&amp;lt;sub&amp;gt;/resourceGroups/&lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt;/providers/Microsoft.Network/virtualNetworks/&lt;span class="nv"&gt;$VNET_NAME&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--allow-vnet-access&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  5. Link Private DNS to Underlay VNet
&lt;/h2&gt;

&lt;p&gt;Without this step, the underlay cluster resolves &lt;strong&gt;public SQL endpoints&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network private-dns &lt;span class="nb"&gt;link &lt;/span&gt;vnet create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource-group&lt;/span&gt; &lt;span class="nv"&gt;$RG_NETWORK&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--zone-name&lt;/span&gt; privatelink.database.windows.net &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; link-underlay-vnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--virtual-network&lt;/span&gt; /subscriptions/&amp;lt;sub&amp;gt;/resourceGroups/&lt;span class="nv"&gt;$RG_UNDERLAY&lt;/span&gt;/providers/Microsoft.Network/virtualNetworks/&lt;span class="nv"&gt;$VNET_UNDERLAY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--registration-enabled&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  6. Communication Testing Scenarios
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Scenario 1 — Overlay Pod → Underlay Pod
&lt;/h2&gt;

&lt;p&gt;Get underlay pod IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.1.1.18
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From overlay pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;--context&lt;/span&gt; aks-overlay &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; overlay-test &lt;span class="nt"&gt;--&lt;/span&gt; sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ping 10.1.1.18
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Success proves &lt;strong&gt;cross-region VNet routing works&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Scenario 2 — Underlay Pod → Azure SQL Private Endpoint
&lt;/h2&gt;

&lt;p&gt;Enter underlay pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; underlay-test &lt;span class="nt"&gt;--&lt;/span&gt; sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nslookup &amp;lt;sql-server&amp;gt;.database.windows.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.0.4.4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test SQL port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nc &lt;span class="nt"&gt;-zv&lt;/span&gt; &amp;lt;sql-server&amp;gt;.database.windows.net 1433
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Connection succeeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Scenario 3 — Overlay Pod → Underlay Service
&lt;/h2&gt;

&lt;p&gt;Deploy nginx in underlay cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create deployment nginx &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expose via NodePort:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl expose deployment nginx &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--type&lt;/span&gt; NodePort &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--port&lt;/span&gt; 80 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; nginx-nodeport
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check NodePort:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;80:31904/TCP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Get node IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.1.1.33
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test from overlay pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;--context&lt;/span&gt; aks-overlay &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; overlay-test &lt;span class="nt"&gt;--&lt;/span&gt; sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;wget -qO- http://10.1.1.33:31904
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Welcome to nginx!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Key Networking Differences
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Overlay&lt;/th&gt;
&lt;th&gt;Underlay&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pod IP&lt;/td&gt;
&lt;td&gt;Overlay CIDR&lt;/td&gt;
&lt;td&gt;VNet subnet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VNet IP usage&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subnet exhaustion risk&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct VNet reachability&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Final Result
&lt;/h2&gt;

&lt;p&gt;This lab demonstrates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Overlay networking&lt;/li&gt;
&lt;li&gt;Underlay networking&lt;/li&gt;
&lt;li&gt;Cross-region VNet peering&lt;/li&gt;
&lt;li&gt;AKS pod communication&lt;/li&gt;
&lt;li&gt;Azure SQL Private Endpoint connectivity&lt;/li&gt;
&lt;li&gt;Kubernetes service exposure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftxt30ld0j5kw793hunh5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftxt30ld0j5kw793hunh5.png" alt="Network Design" width="683" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aks</category>
      <category>azure</category>
      <category>networking</category>
      <category>cloud</category>
    </item>
  </channel>
</rss>
