<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>I&apos;m Gatan</title><description>Personal Site &amp; Blog</description><link>https://im-gatan.com/</link><language>en</language><item><title>Getting Started with Istio: Installing the Service Mesh &amp; Running the Bookinfo Demo on Kubernetes</title><link>https://im-gatan.com/posts/istio-service-mesh-kubernetes-setup/</link><guid isPermaLink="true">https://im-gatan.com/posts/istio-service-mesh-kubernetes-setup/</guid><description>A step-by-step guide to installing Istio with istioctl on a single-node Kubernetes cluster, deploying the Bookinfo sample application, exposing it without a cloud LoadBalancer, visualizing traffic with the Kiali dashboard, and testing traffic routing with VirtualServices.</description><pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;One of the most common next steps after setting up a Kubernetes cluster is adding a &lt;strong&gt;service mesh&lt;/strong&gt;. &lt;a href=&quot;https://istio.io/&quot;&gt;Istio&lt;/a&gt; is the most widely adopted service mesh in the CNCF ecosystem — it gives you automatic mTLS encryption, traffic routing, observability, and resilience features without changing a single line of application code.&lt;/p&gt;
&lt;p&gt;This guide walks through the full workflow:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Installing &lt;code&gt;istioctl&lt;/code&gt; and the Istio control plane (default profile)&lt;/li&gt;
&lt;li&gt;Enabling automatic Envoy sidecar injection&lt;/li&gt;
&lt;li&gt;Deploying and exploring the &lt;strong&gt;Bookinfo&lt;/strong&gt; sample application&lt;/li&gt;
&lt;li&gt;Exposing the app on a bare-metal / single-node cluster (no cloud LoadBalancer required)&lt;/li&gt;
&lt;li&gt;Visualizing your service mesh with the &lt;strong&gt;Kiali&lt;/strong&gt; dashboard&lt;/li&gt;
&lt;li&gt;Testing traffic routing with VirtualServices and DestinationRules&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Official reference:&lt;/strong&gt; &lt;a href=&quot;https://istio.io/latest/docs/setup/install/istioctl/&quot;&gt;Istio Installation Guide&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Configuration Placeholders&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Placeholder&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;NODE_IP&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;IP address of your Kubernetes node&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.1.100&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;NODE_PORT&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NodePort assigned by Kubernetes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;32192&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;ISTIO_VERSION&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Istio release version downloaded&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1.31.0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Environment Specifications&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kubernetes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Single-node cluster provisioned with kubeadm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CNI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Calico&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Istio Version&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.31.x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Install Method&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;istioctl&lt;/code&gt; (CLI binary)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Profile&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;default&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Since this is a bare-metal single-node cluster without a cloud LoadBalancer (not GKE/EKS/AKS), services of type &lt;code&gt;LoadBalancer&lt;/code&gt; created by the Istio IngressGateway will never receive an &lt;code&gt;EXTERNAL-IP&lt;/code&gt;. This guide includes alternative access methods via &lt;code&gt;port-forward&lt;/code&gt; and &lt;code&gt;NodePort&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;Before starting, make sure the following are in place:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A single-node Kubernetes cluster in &lt;code&gt;Ready&lt;/code&gt; state (see &lt;a href=&quot;/posts/kubernetes-single-host-setup/&quot;&gt;Kubernetes Single-Node Setup on AlmaLinux 9&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kubectl&lt;/code&gt; configured and able to access the cluster (&lt;code&gt;kubectl get nodes&lt;/code&gt; shows &lt;code&gt;Ready&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Sufficient resources on the node to run the Istio control plane + 4 Bookinfo microservices (at least &lt;strong&gt;2 CPU / 4 GB RAM&lt;/strong&gt; free is recommended)&lt;/li&gt;
&lt;li&gt;Internet access from the node (to download the Istio binary and pull container images)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 1: Download and Install istioctl&lt;/h2&gt;
&lt;h3&gt;1.1 Download the Istio Release&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;curl -L https://istio.io/downloadIstio | sh -
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Downloading istio-&amp;lt;ISTIO_VERSION&amp;gt; from https://github.com/istio/istio/releases/download/&amp;lt;ISTIO_VERSION&amp;gt;/istio-&amp;lt;ISTIO_VERSION&amp;gt;-linux-amd64.tar.gz ...

Istio &amp;lt;ISTIO_VERSION&amp;gt; download complete!

The Istio release archive has been downloaded to the istio-&amp;lt;ISTIO_VERSION&amp;gt; directory.

To configure the istioctl client tool for your workstation,
add the /home/user/istio-&amp;lt;ISTIO_VERSION&amp;gt;/bin directory to your environment path variable with:
         export PATH=&quot;$PATH:/home/user/istio-&amp;lt;ISTIO_VERSION&amp;gt;/bin&quot;

Begin the Istio pre-installation check by running:
         istioctl x precheck
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Navigate into the extracted directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd istio-*/
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1.2 Add istioctl to PATH&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;export PATH=$PWD/bin:$PATH

# To make it permanent, append to your shell profile
echo &apos;export PATH=$HOME/istio-&amp;lt;ISTIO_VERSION&amp;gt;/bin:$PATH&apos; &amp;gt;&amp;gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; Replace &lt;code&gt;&amp;lt;ISTIO_VERSION&amp;gt;&lt;/code&gt; with the actual version that was downloaded (e.g., &lt;code&gt;1.31.0&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;1.3 Verify istioctl&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;istioctl version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Istio is not present in the cluster: no running Istio pods in namespace &quot;istio-system&quot;
1.31.0
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;The first line is expected — Istio hasn&apos;t been installed on the cluster yet. The important thing is that the &lt;code&gt;istioctl&lt;/code&gt; client version is printed.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 2: Install Istio with the Default Profile&lt;/h2&gt;
&lt;h3&gt;2.1 Pre-installation Check (optional but recommended)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;istioctl x precheck
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;✔ No issues found when checking the cluster. Istio is safe to install or upgrade!
  To get started, check out https://istio.io/latest/docs/setup/getting-started/.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure there are no critical warnings or errors before proceeding.&lt;/p&gt;
&lt;h3&gt;2.2 Install the Default Profile&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;istioctl install --set profile=default -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This command installs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;istiod&lt;/code&gt;&lt;/strong&gt; (the control plane) in the &lt;code&gt;istio-system&lt;/code&gt; namespace&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;istio-ingressgateway&lt;/code&gt;&lt;/strong&gt; as the default ingress gateway&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;✔ Istio core installed ⛵️
✔ Istiod installed 🧠
✔ Ingress gateways installed 🛬
✔ Installation complete
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 3: Verify the Installation&lt;/h2&gt;
&lt;h3&gt;3.1 Check Pods in istio-system&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl get pods -n istio-system
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME                                    READY   STATUS    RESTARTS   AGE
istio-ingressgateway-6dcdbcd758-z6mtb   1/1     Running   0          2m10s
istiod-84568cdf7c-tv9mt                 1/1     Running   0          2m24s
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.2 Check Control Plane Version&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;istioctl version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;client version: 1.31.0
control plane version: 1.31.0
data plane version: 1.31.0 (1 proxies)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now &lt;code&gt;istioctl&lt;/code&gt; can read both the client and control plane versions.&lt;/p&gt;
&lt;h3&gt;3.3 Analyze Configuration (optional)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;istioctl analyze -A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use this to detect potential configuration issues across all namespaces before deploying any applications.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 4: Enable Automatic Sidecar Injection&lt;/h2&gt;
&lt;p&gt;Istio automatically injects an Envoy sidecar proxy into every pod deployed in namespaces labeled with &lt;code&gt;istio-injection=enabled&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl label namespace default istio-injection=enabled
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify the label:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get namespace -L istio-injection
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME              STATUS   AGE     ISTIO-INJECTION
default           Active   4h29m   enabled
istio-system      Active   3m26s
kube-system       Active   4h29m
...
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 5: Deploy the Bookinfo Application&lt;/h2&gt;
&lt;p&gt;Bookinfo is Istio&apos;s official sample application, consisting of 4 microservices (&lt;code&gt;productpage&lt;/code&gt;, &lt;code&gt;details&lt;/code&gt;, &lt;code&gt;reviews&lt;/code&gt;, &lt;code&gt;ratings&lt;/code&gt;) with 3 different versions of the &lt;code&gt;reviews&lt;/code&gt; service (v1, v2, v3) — perfect for demonstrating traffic routing capabilities.&lt;/p&gt;
&lt;h3&gt;5.1 Apply the Bookinfo Manifest&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f samples/bookinfo/platform/kube/bookinfo.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;Make sure you are still inside the extracted Istio directory (&lt;code&gt;istio-&amp;lt;ISTIO_VERSION&amp;gt;/&lt;/code&gt;) so that the &lt;code&gt;samples/bookinfo/...&lt;/code&gt; path resolves correctly.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;5.2 Verify Services and Pods&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl get services
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME          TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)    AGE
details       ClusterIP   10.102.162.188   &amp;lt;none&amp;gt;        9080/TCP   7s
kubernetes    ClusterIP   10.96.0.1        &amp;lt;none&amp;gt;        443/TCP    4h30m
productpage   ClusterIP   10.111.175.168   &amp;lt;none&amp;gt;        9080/TCP   7s
ratings       ClusterIP   10.108.180.245   &amp;lt;none&amp;gt;        9080/TCP   7s
reviews       ClusterIP   10.99.206.200    &amp;lt;none&amp;gt;        9080/TCP   7s
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;kubectl get pods
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure each pod shows &lt;strong&gt;2/2&lt;/strong&gt; in the &lt;code&gt;READY&lt;/code&gt; column — the &lt;code&gt;2&lt;/code&gt; indicates that the Envoy sidecar was successfully injected alongside the application container.&lt;/p&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME                              READY   STATUS    RESTARTS   AGE
details-v1-65bd58d55f-svxqk       2/2     Running   0          7m32s
productpage-v1-7498b5bd44-4s76z   2/2     Running   0          7m32s
ratings-v1-9bff6cb79-7zpjv        2/2     Running   0          7m32s
reviews-v1-54465994f9-47h5d       2/2     Running   0          7m32s
reviews-v2-577579b4c5-6vvfl       2/2     Running   0          7m32s
reviews-v3-6d7944df59-tlr6r       2/2     Running   0          7m32s
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;If the &lt;code&gt;READY&lt;/code&gt; column shows 1/1&lt;/strong&gt;, the sidecar was not injected. Double-check the namespace label from Step 4, then restart the deployments with &lt;code&gt;kubectl rollout restart deployment&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;5.3 Test from Inside the Cluster&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl exec &quot;$(kubectl get pod -l app=ratings -o jsonpath=&apos;{.items[0].metadata.name}&apos;)&quot; \
  -c ratings -- curl -sS productpage:9080/productpage | grep -o &quot;&amp;lt;title&amp;gt;.*&amp;lt;/title&amp;gt;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;title&amp;gt;Simple Bookstore App&amp;lt;/title&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.4 Create the Istio Gateway&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f samples/bookinfo/networking/bookinfo-gateway.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gateway.networking.istio.io/bookinfo-gateway created
virtualservice.networking.istio.io/bookinfo created
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get gateway
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME               AGE
bookinfo-gateway   24s
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 6: Expose Bookinfo on a Single-Node Cluster&lt;/h2&gt;
&lt;p&gt;This is where the setup &lt;strong&gt;diverges from the official Istio documentation&lt;/strong&gt;, which assumes your cluster has a cloud LoadBalancer. On a bare-metal single-node cluster, &lt;code&gt;istio-ingressgateway&lt;/code&gt; will remain stuck in &lt;code&gt;Pending&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get svc istio-ingressgateway -n istio-system
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME                   TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)                                      AGE
istio-ingressgateway   LoadBalancer   10.100.98.219   &amp;lt;pending&amp;gt;     15021:32283/TCP,80:32192/TCP,443:32707/TCP   12m
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Choose one of the options below to access Bookinfo from outside the cluster.&lt;/p&gt;
&lt;h3&gt;Option A — Port Forward (fastest for demos)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl port-forward -n istio-system svc/istio-ingressgateway 8080:80
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keep this terminal running, then access the app from your browser or via &lt;code&gt;curl&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://&amp;lt;NODE_IP&amp;gt;:8080/productpage
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;If accessing from the server itself, use &lt;code&gt;http://localhost:8080/productpage&lt;/code&gt;. If accessing from another machine (e.g., your laptop), make sure port 8080 is open in the firewall and use the server&apos;s IP address.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;Option B — Patch to NodePort (more permanent)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl patch svc istio-ingressgateway -n istio-system -p &apos;{&quot;spec&quot;: {&quot;type&quot;: &quot;NodePort&quot;}}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check the NodePort assigned to port &lt;code&gt;80&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get svc istio-ingressgateway -n istio-system -o jsonpath=&apos;{.spec.ports[?(@.port==80)].nodePort}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then access via:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://&amp;lt;NODE_IP&amp;gt;:&amp;lt;NODE_PORT&amp;gt;/productpage
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once accessible, you should see the Bookinfo product page:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./bookinfo-productpage.png&quot; alt=&quot;Bookinfo Product Page&quot; /&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 7: Confirm the App is Working&lt;/h2&gt;
&lt;p&gt;Use &lt;code&gt;curl&lt;/code&gt; to verify the application is accessible (example using port-forward from Option A):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -s &quot;http://localhost:8080/productpage&quot; | grep -o &quot;&amp;lt;title&amp;gt;.*&amp;lt;/title&amp;gt;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;title&amp;gt;Simple Bookstore App&amp;lt;/title&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Open the page in your browser and &lt;strong&gt;refresh several times&lt;/strong&gt; — you&apos;ll notice the &lt;code&gt;reviews&lt;/code&gt; section cycling through different versions in round-robin fashion: no stars, black stars, and red stars. This happens because no routing rules have been applied yet, so traffic is distributed equally across all three versions.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./bookinfo-reviews-round-robin.png&quot; alt=&quot;Bookinfo Reviews Round Robin — versions cycling without routing rules&quot; /&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 8: Define Service Versions with DestinationRules&lt;/h2&gt;
&lt;p&gt;Before configuring traffic routing between &lt;code&gt;reviews&lt;/code&gt; versions (e.g., request routing or traffic shifting), Istio needs to know which subsets/versions are available. This is done through &lt;code&gt;DestinationRule&lt;/code&gt; resources.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f samples/bookinfo/networking/destination-rule-all.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;destinationrule.networking.istio.io/productpage created
destinationrule.networking.istio.io/reviews created
destinationrule.networking.istio.io/ratings created
destinationrule.networking.istio.io/details created
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get destinationrules -o yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;The &lt;code&gt;default&lt;/code&gt; profile already enables &lt;strong&gt;auto mutual TLS&lt;/strong&gt;, so you don&apos;t need to set up mTLS manually for this demo.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 9: Kiali Dashboard — Visualizing Your Service Mesh&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://kiali.io/&quot;&gt;Kiali&lt;/a&gt; is the official observability console for Istio. It provides a real-time visual representation of your service mesh topology, traffic flow, health status, and configuration validation. Istio ships Kiali as part of a bundle of observability addons alongside &lt;strong&gt;Prometheus&lt;/strong&gt; (metrics), &lt;strong&gt;Grafana&lt;/strong&gt; (dashboards), and &lt;strong&gt;Jaeger&lt;/strong&gt; (distributed tracing).&lt;/p&gt;
&lt;h3&gt;9.1 Install the Observability Addons&lt;/h3&gt;
&lt;p&gt;Install all addons included in the Istio samples directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f samples/addons
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;serviceaccount/grafana created
configmap/grafana created
service/grafana created
deployment.apps/grafana created
configmap/istio-grafana-dashboards created
configmap/istio-services-grafana-dashboards created
deployment.apps/jaeger created
service/tracing created
serviceaccount/kiali created
configmap/kiali created
clusterrole.rbac.authorization.k8s.io/kiali created
clusterrolebinding.rbac.authorization.k8s.io/kiali created
service/kiali created
deployment.apps/kiali created
serviceaccount/prometheus created
configmap/prometheus created
service/prometheus created
deployment.apps/prometheus created
...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;9.2 Wait for Kiali to Be Ready&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl rollout status deployment/kiali -n istio-system
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Waiting for deployment &quot;kiali&quot; rollout to finish: 0 of 1 updated replicas are available...
deployment &quot;kiali&quot; successfully rolled out
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;9.3 Access the Kiali Dashboard&lt;/h3&gt;
&lt;p&gt;To access the dashboard, patch the Kiali service to &lt;code&gt;NodePort&lt;/code&gt; — the same approach used to expose the IngressGateway:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl patch svc kiali -n istio-system -p &apos;{&quot;spec&quot;: {&quot;type&quot;: &quot;NodePort&quot;}}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check the NodePort assigned to port &lt;code&gt;20001&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get svc kiali -n istio-system -o jsonpath=&apos;{.spec.ports[?(@.port==20001)].nodePort}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then open the dashboard in your browser at &lt;code&gt;http://&amp;lt;NODE_IP&amp;gt;:&amp;lt;NODE_PORT&amp;gt;&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./kiali-dashboard-overview.png&quot; alt=&quot;Kiali Dashboard Overview&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Navigate to the &lt;strong&gt;Graph&lt;/strong&gt; section — Kiali displays a live visualization of your service mesh, showing the relationships between services in the Bookinfo application. It also provides filters to help you analyze traffic flow across the mesh.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./kiali-graph-view.png&quot; alt=&quot;Kiali Graph — service mesh topology and traffic flow&quot; /&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 10: Test Traffic Routing with VirtualServices&lt;/h2&gt;
&lt;p&gt;Since the DestinationRules (with subsets v1/v2/v3) were applied in the previous step, you can now create a &lt;code&gt;VirtualService&lt;/code&gt; to route 100% of traffic to a specific version. Let&apos;s route all &lt;code&gt;reviews&lt;/code&gt; traffic to &lt;strong&gt;v2&lt;/strong&gt; (black stars):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat &amp;lt;&amp;lt;EOF | kubectl apply -f -
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: reviews
spec:
  hosts:
  - reviews
  http:
  - route:
    - destination:
        host: reviews
        subset: v2
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify the routing rule:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get virtualservice reviews -o yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Refresh &lt;code&gt;/productpage&lt;/code&gt; several times — now the reviews section will &lt;strong&gt;always&lt;/strong&gt; show black stars (v2 fetches rating data but displays stars in black, unlike v3 which shows red stars). No more round-robin cycling between versions.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./bookinfo-reviews-v2.png&quot; alt=&quot;Bookinfo pinned to Reviews v2 — consistent black stars&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can also confirm this in the Kiali graph — traffic to the &lt;code&gt;reviews&lt;/code&gt; service now flows exclusively to the v2 subset, with no traffic going to v1 or v3:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./kiali-verify-v2-routing.png&quot; alt=&quot;Kiali verification — all traffic routed to Reviews v2&quot; /&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Cleanup&lt;/h2&gt;
&lt;p&gt;Once you&apos;re done exploring, clean up all the resources:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Remove Bookinfo and its gateway
samples/bookinfo/platform/kube/cleanup.sh

# (Optional) Revert services back to ClusterIP if you patched them to NodePort
kubectl patch svc istio-ingressgateway -n istio-system -p &apos;{&quot;spec&quot;: {&quot;type&quot;: &quot;ClusterIP&quot;}}&apos;

# Uninstall Istio from the cluster
istioctl uninstall --purge -y

# Delete the istio-system namespace
kubectl delete namespace istio-system
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Troubleshooting&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Resolution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sidecar not injected (pods show 1/1 instead of 2/2)&lt;/td&gt;
&lt;td&gt;Namespace missing &lt;code&gt;istio-injection&lt;/code&gt; label&lt;/td&gt;
&lt;td&gt;Run &lt;code&gt;kubectl get namespace default --show-labels&lt;/code&gt; and re-apply the label, then &lt;code&gt;kubectl rollout restart deployment -n default&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;istio-ingressgateway&lt;/code&gt; stuck in &lt;code&gt;&amp;lt;pending&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No cloud LoadBalancer on bare-metal&lt;/td&gt;
&lt;td&gt;Use Option A (port-forward) or Option B (NodePort) from Step 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;productpage&lt;/code&gt; returns 503 / upstream connect error&lt;/td&gt;
&lt;td&gt;Sidecar not fully ready or DestinationRules not applied&lt;/td&gt;
&lt;td&gt;Check &lt;code&gt;kubectl get pods -n default&lt;/code&gt; and inspect logs with &lt;code&gt;kubectl logs &amp;lt;pod&amp;gt; -c istio-proxy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;istiod&lt;/code&gt; in CrashLoopBackOff&lt;/td&gt;
&lt;td&gt;Insufficient CPU/RAM on the node&lt;/td&gt;
&lt;td&gt;Check &lt;code&gt;kubectl describe pod -n istio-system -l app=istiod&lt;/code&gt; and &lt;code&gt;kubectl top nodes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://istio.io/latest/docs/setup/install/istioctl/&quot;&gt;Install with Istioctl — istio.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://istio.io/latest/docs/examples/bookinfo/&quot;&gt;Bookinfo Application — istio.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://istio.io/latest/docs/setup/additional-setup/config-profiles/&quot;&gt;Installation Configuration Profiles — istio.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://kiali.io/&quot;&gt;Kiali — Service Mesh Observability&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/kubernetes-single-host-setup/&quot;&gt;Kubernetes Single-Node Setup on AlmaLinux 9 — im-gatan.com&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>HashiCorp Vault + MariaDB: Eliminating Static Credentials with Dynamic Secrets</title><link>https://im-gatan.com/posts/hashicorp-vault-mariadb-dynamic-credentials/</link><guid isPermaLink="true">https://im-gatan.com/posts/hashicorp-vault-mariadb-dynamic-credentials/</guid><description>A hands-on guide to setting up HashiCorp Vault&apos;s Database Secrets Engine with MariaDB, enabling on-demand dynamic credentials with automatic TTL-based revocation — no more static passwords in config files or environment variables.</description><pubDate>Wed, 19 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;One of the most powerful — and underutilized — features of &lt;strong&gt;HashiCorp Vault&lt;/strong&gt; is its &lt;strong&gt;Database Secrets Engine&lt;/strong&gt;. Instead of embedding static database passwords in your &lt;code&gt;.env&lt;/code&gt; files, application configs, or CI/CD pipelines, Vault generates &lt;em&gt;short-lived, on-demand credentials&lt;/em&gt; that are automatically revoked when they expire.&lt;/p&gt;
&lt;p&gt;This guide walks through the full setup:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Installing Vault in dev mode (local testing)&lt;/li&gt;
&lt;li&gt;Running MariaDB via Docker Compose&lt;/li&gt;
&lt;li&gt;Enabling and configuring the Database Secrets Engine&lt;/li&gt;
&lt;li&gt;Creating &lt;code&gt;db-admin&lt;/code&gt; and &lt;code&gt;db-read-only&lt;/code&gt; roles&lt;/li&gt;
&lt;li&gt;Generating and verifying dynamic credentials against MariaDB&lt;/li&gt;
&lt;li&gt;Managing leases and manual revocation&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Official reference:&lt;/strong&gt; &lt;a href=&quot;https://developer.hashicorp.com/vault/docs/secrets/databases/mysql-maria&quot;&gt;Vault MySQL/MariaDB Database Secrets Engine&lt;/a&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Vault CLI installed on your machine&lt;/li&gt;
&lt;li&gt;Docker &amp;amp; Docker Compose (to run MariaDB)&lt;/li&gt;
&lt;li&gt;A Unix-like terminal (Linux, macOS, or WSL on Windows)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Configuration Placeholders&lt;/h2&gt;
&lt;p&gt;The table below lists the placeholder variables used throughout this guide. Replace them with your environment&apos;s actual values:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Placeholder&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;MARIADB_HOST&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;IP or hostname of the MariaDB server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.1.100&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;MARIADB_PORT&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MariaDB port&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3306&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;MARIADB_ROOT_PASSWORD&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Root password for MariaDB&lt;/td&gt;
&lt;td&gt;&lt;code&gt;YourStrongPassword&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;VAULT_HOST&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;IP or hostname of the Vault server (non-dev mode)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.1.50&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;VAULT_PORT&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Vault API port&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8200&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 1: Install Vault (Dev Mode)&lt;/h2&gt;
&lt;p&gt;For local testing and exploration, you don&apos;t need a full production Vault setup (unsealing, persistent storage backends, etc.). The &lt;strong&gt;dev server&lt;/strong&gt; starts pre-unsealed with an in-memory storage backend — perfect for learning.&lt;/p&gt;
&lt;h3&gt;macOS (Homebrew)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;brew tap hashicorp/tap
brew install hashicorp/tap/vault
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Linux (Debian/Ubuntu)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update
sudo apt install -y gnupg wget lsb-release

wget -O- https://apt.releases.hashicorp.com/gpg \
  | gpg --dearmor \
  | sudo tee /usr/share/keyrings/hashicorp-archive-keyring.gpg &amp;gt; /dev/null

echo &quot;deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main&quot; \
  | sudo tee /etc/apt/sources.list.d/hashicorp.list

sudo apt update
sudo apt install -y vault
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Verify Installation&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;vault --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Vault v2.0.4 (c9e9d1d4ddd4b55aae79a8949adffa9e96338720), built 2026-08-03T16:14:36Z
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Start the Dev Server&lt;/h3&gt;
&lt;p&gt;Open a dedicated terminal window and run:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vault server -dev -dev-root-token-id=&quot;root&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WARNING! dev mode is enabled! In this mode, Vault runs entirely in-memory
and starts unsealed with a single unseal key. The root token is already
authenticated to the CLI, so you can immediately begin using Vault.

You may need to set the following environment variables:

    $ export VAULT_ADDR=&apos;http://127.0.0.1:8200&apos;

The unseal key and root token are displayed below in case you want to
seal/unseal the Vault or re-authenticate.

Unseal Key: c+pRvn/2UC6tGpAH5ucQ9Qejufbxnpl/u0UuHTWnoKc=
Root Token: root

Development mode should NOT be used in production installations!
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Vault will start listening on &lt;code&gt;http://127.0.0.1:8200&lt;/code&gt; with the root token &lt;code&gt;root&lt;/code&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; Dev mode stores all data in memory and resets on restart. &lt;strong&gt;Never use dev mode in production.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In a second terminal, export the required environment variables so the CLI knows where to connect:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export VAULT_ADDR=&apos;http://127.0.0.1:8200&apos;
export VAULT_TOKEN=&apos;root&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify connectivity:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vault status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Key             Value
---             -----
Seal Type       shamir
Initialized     true
Sealed          false
Total Shares    1
Threshold       1
Version         2.0.4
Build Date      2026-08-03T16:14:36Z
Storage Type    inmem
Cluster Name    vault-cluster-b032d240
Cluster ID      3ae78de0-3009-c9b6-5993-caba26940ba5
HA Enabled      false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output will show &lt;code&gt;Sealed: false&lt;/code&gt; — confirming Vault is running and unsealed.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Alternative: Run Vault Without Dev Mode (via &lt;code&gt;config.hcl&lt;/code&gt;)&lt;/h2&gt;
&lt;p&gt;Dev mode is great for quick exploration, but it stores everything &lt;strong&gt;in-memory&lt;/strong&gt; (wiped on process exit) and starts fully unsealed automatically — which doesn&apos;t reflect how Vault behaves in a real environment. If you want a more realistic setup (persistent data, manual init/unseal), use a configuration file instead.&lt;/p&gt;
&lt;h3&gt;1. Create &lt;code&gt;config.hcl&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;storage &quot;file&quot; {
  path = &quot;/opt/vault/data&quot;
}

listener &quot;tcp&quot; {
  address     = &quot;&amp;lt;VAULT_HOST&amp;gt;:&amp;lt;VAULT_PORT&amp;gt;&quot;
  tls_disable = true
}

api_addr = &quot;http://&amp;lt;VAULT_HOST&amp;gt;:&amp;lt;VAULT_PORT&amp;gt;&quot;
ui       = true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Configuration breakdown:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&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;storage &quot;file&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Persists all Vault data to disk. For HA environments, use &lt;code&gt;storage &quot;raft&quot;&lt;/code&gt; instead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;listener &quot;tcp&quot; address&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replace &lt;code&gt;&amp;lt;VAULT_HOST&amp;gt;&lt;/code&gt; with the server&apos;s IP/hostname and &lt;code&gt;&amp;lt;VAULT_PORT&amp;gt;&lt;/code&gt; with the desired port&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tls_disable = true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Allows plain HTTP access without a certificate — &lt;strong&gt;testing/development only&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;api_addr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The address Vault advertises to clients and cluster peers; must match &lt;code&gt;listener&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Create the storage directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /opt/vault/data
sudo chown -R $(whoami) /opt/vault/data
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. Start Vault with the Config File&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;vault server -config=config.hcl
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;==&amp;gt; Vault server configuration:

                     Cgo: disabled
              Go Version: go1.26.5
              Listener 1: tcp (addr: &quot;&amp;lt;VAULT_HOST&amp;gt;:8200&quot;, cluster address: &quot;&amp;lt;VAULT_HOST&amp;gt;:8201&quot;, tls: &quot;disabled&quot;)
                   Mlock: supported: true, enabled: false
           Recovery Mode: false
                 Storage: file
                 Version: Vault v2.0.4, built 2026-08-03T16:14:36Z

==&amp;gt; Vault server started! Log data will stream in below:

[INFO]  proxy environment: http_proxy=&quot;&quot; https_proxy=&quot;&quot; no_proxy=&quot;&quot;
[INFO]  incrementing seal generation: generation=1
[INFO]  core: Initializing version history cache for core
[INFO]  events: Starting event system
[INFO]  core: security barrier not initialized
[INFO]  core: seal configuration missing, not initialized
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In a new terminal, point the CLI at your Vault instance:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export VAULT_ADDR=&apos;http://&amp;lt;VAULT_HOST&amp;gt;:&amp;lt;VAULT_PORT&amp;gt;&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Initialize Vault&lt;/h3&gt;
&lt;p&gt;Unlike dev mode, a fresh Vault instance &lt;strong&gt;must be initialized&lt;/strong&gt; before use. This process generates the unseal keys and the initial root token — store them securely, as they are only shown once:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vault operator init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Unseal Key 1: xxxxx
Unseal Key 2: xxxxx
Unseal Key 3: xxxxx
Unseal Key 4: xxxxx
Unseal Key 5: xxxxx

Initial Root Token: hvs.xxxxxxxxxxxxxxxx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By default, Vault requires &lt;strong&gt;3 out of 5 unseal keys&lt;/strong&gt; (configurable via &lt;code&gt;-key-shares&lt;/code&gt; and &lt;code&gt;-key-threshold&lt;/code&gt; flags on &lt;code&gt;vault operator init&lt;/code&gt;).&lt;/p&gt;
&lt;h3&gt;4. Unseal Vault&lt;/h3&gt;
&lt;p&gt;Run &lt;code&gt;vault operator unseal&lt;/code&gt; once per key, up to the threshold (default: 3 times), providing a different key each time:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vault operator unseal   # Enter Unseal Key 1
vault operator unseal   # Enter Unseal Key 2
vault operator unseal   # Enter Unseal Key 3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify the status:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vault status
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Confirm that &lt;code&gt;Sealed&lt;/code&gt; shows &lt;code&gt;false&lt;/code&gt; before proceeding.&lt;/p&gt;
&lt;h3&gt;5. Log In with the Root Token&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;vault login
# Enter the Initial Root Token from `vault operator init`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once logged in, all subsequent steps — enabling the Database Secrets Engine, configuring MariaDB, creating roles, and generating credentials — are &lt;strong&gt;identical&lt;/strong&gt; to the dev mode workflow below. Just make sure &lt;code&gt;VAULT_ADDR&lt;/code&gt; points to &lt;code&gt;http://&amp;lt;VAULT_HOST&amp;gt;:&amp;lt;VAULT_PORT&amp;gt;&lt;/code&gt; instead of &lt;code&gt;127.0.0.1:8200&lt;/code&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Security note:&lt;/strong&gt; The setup above (&lt;code&gt;tls_disable = true&lt;/code&gt;, single-node manual unseal) is appropriate only for testing and development on trusted/private networks. For production deployments, always enable TLS, consider auto-unseal (e.g., via cloud KMS), and restrict network access to the Vault port.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 2: Set Up MariaDB (Docker Compose)&lt;/h2&gt;
&lt;p&gt;Create a &lt;code&gt;docker-compose.yml&lt;/code&gt; file to spin up a MariaDB instance:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  database:
    container_name: mariadb
    image: mariadb:latest
    restart: always
    ports:
      - &quot;&amp;lt;MARIADB_PORT&amp;gt;:3306&quot;
    environment:
      - MARIADB_ROOT_PASSWORD=&amp;lt;MARIADB_ROOT_PASSWORD&amp;gt;
    volumes:
      - mariadb-data:/var/lib/mysql

volumes:
  mariadb-data:
    name: mariadb-data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Start the container:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify it&apos;s running:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker ps
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CONTAINER ID   IMAGE            COMMAND                  CREATED         STATUS         PORTS                    NAMES
de13ac6ba5cb   mariadb:latest   &quot;docker-entrypoint.s…&quot;   4 seconds ago   Up 3 seconds   0.0.0.0:3306-&amp;gt;3306/tcp   mariadb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this configuration, MariaDB is accessible at:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Host&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;MARIADB_HOST&amp;gt;&lt;/code&gt; (or &lt;code&gt;127.0.0.1&lt;/code&gt; if Vault runs on the same machine)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Port&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;MARIADB_PORT&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;User&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;root&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Password&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;MARIADB_ROOT_PASSWORD&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The &lt;code&gt;root&lt;/code&gt; credentials are what Vault uses as its &lt;strong&gt;management account&lt;/strong&gt; — Vault will authenticate as &lt;code&gt;root&lt;/code&gt; to create and revoke dynamic user accounts on your behalf.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 3: Configure Vault Database Secrets Engine&lt;/h2&gt;
&lt;p&gt;Before proceeding, make sure that MariaDB at &lt;code&gt;&amp;lt;MARIADB_HOST&amp;gt;:&amp;lt;MARIADB_PORT&amp;gt;&lt;/code&gt; is reachable from the machine where Vault is running.&lt;/p&gt;
&lt;h3&gt;3.1 Enable the Database Secrets Engine&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;vault secrets enable database
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Success! Enabled the database secrets engine at: database/
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.2 Configure the Database Connection&lt;/h3&gt;
&lt;p&gt;Tell Vault how to connect to MariaDB. Vault uses the &lt;code&gt;mysql-database-plugin&lt;/code&gt;, which is fully compatible with MariaDB:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vault write database/config/mariadb \
  plugin_name=mysql-database-plugin \
  connection_url=&quot;{{username}}:{{password}}@tcp(&amp;lt;MARIADB_HOST&amp;gt;:&amp;lt;MARIADB_PORT&amp;gt;)/&quot; \
  allowed_roles=&quot;*&quot; \
  username=&quot;root&quot; \
  password=&quot;&amp;lt;MARIADB_ROOT_PASSWORD&amp;gt;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Parameter breakdown:&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&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;connection_url&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;MariaDB address with &lt;code&gt;{{username}}&lt;/code&gt; and &lt;code&gt;{{password}}&lt;/code&gt; placeholders that Vault fills in automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;allowed_roles=&quot;*&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Permits all roles defined under this connection to use it. For production, restrict to explicit role names (e.g., &lt;code&gt;&quot;db-admin,db-read-only&quot;&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;username&lt;/code&gt; / &lt;code&gt;password&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The management credentials Vault uses to create and drop dynamic users&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;3.3 Create an Admin Role&lt;/h3&gt;
&lt;p&gt;This role generates users with full &lt;code&gt;ALL PRIVILEGES&lt;/code&gt; — suitable for administrative or migration workloads:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vault write database/roles/db-admin \
  db_name=mariadb \
  creation_statements=&quot;CREATE USER &apos;{{name}}&apos;@&apos;%&apos; IDENTIFIED BY &apos;{{password}}&apos;;&quot; \
  creation_statements=&quot;GRANT ALL PRIVILEGES ON *.* TO &apos;{{name}}&apos;@&apos;%&apos;;&quot; \
  revocation_statements=&quot;DROP USER &apos;{{name}}&apos;@&apos;%&apos;;&quot; \
  default_ttl=&quot;1h&quot; \
  max_ttl=&quot;1h&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.4 Create a Read-Only Role&lt;/h3&gt;
&lt;p&gt;This role generates users with &lt;code&gt;SELECT&lt;/code&gt; privileges only — ideal for reporting dashboards, data pipelines, or read replicas:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vault write database/roles/db-read-only \
  db_name=mariadb \
  creation_statements=&quot;CREATE USER &apos;{{name}}&apos;@&apos;%&apos; IDENTIFIED BY &apos;{{password}}&apos;;&quot; \
  creation_statements=&quot;GRANT SELECT ON *.* TO &apos;{{name}}&apos;@&apos;%&apos;;&quot; \
  revocation_statements=&quot;DROP USER &apos;{{name}}&apos;@&apos;%&apos;;&quot; \
  default_ttl=&quot;1h&quot; \
  max_ttl=&quot;1h&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 4: Generate Dynamic Credentials&lt;/h2&gt;
&lt;p&gt;With the roles in place, request credentials on demand. You no longer need to know or manage any static passwords.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Admin credentials:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vault read database/creds/db-admin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Read-only credentials:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vault read database/creds/db-read-only
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Key                Value
---                -----
lease_id           database/creds/db-read-only/2f6a614c-4aa2-7b19-24b9-ad944a8d4de6
lease_duration     1h
lease_renewable    true
password           yY-57n3X5UQhxnmFRP3f
username           v-root-db-read-onl-crBWVqVh2Hc1
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;Each invocation creates a &lt;strong&gt;brand-new MariaDB user&lt;/strong&gt; with a randomly generated username and password. When the TTL expires, Vault automatically executes the &lt;code&gt;revocation_statements&lt;/code&gt; to drop that user from MariaDB.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 5: Verify the Credentials&lt;/h2&gt;
&lt;p&gt;Use the freshly generated credentials to authenticate against MariaDB:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mariadb -h &amp;lt;MARIADB_HOST&amp;gt; \
  -u v-root-db-read-onl-crBWVqVh2Hc1 \
  -pyY-57n3X5UQhxnmFRP3f \
  -e &quot;SHOW DATABASES;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Expected behavior:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Credentials from &lt;code&gt;db-admin&lt;/code&gt; can execute any SQL operation (DDL, DML, etc.)&lt;/li&gt;
&lt;li&gt;Credentials from &lt;code&gt;db-read-only&lt;/code&gt;: &lt;code&gt;SELECT&lt;/code&gt; queries succeed; &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;DROP&lt;/code&gt;, etc., are rejected with an access denied error&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If both behave as expected, the Vault and MariaDB integration is working correctly.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Step 6 (Optional): Lease Management &amp;amp; Manual Revocation&lt;/h2&gt;
&lt;h3&gt;List Active Leases&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;vault list sys/leases/lookup/database/creds/db-read-only
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Revoke a Specific Lease Early&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;vault lease revoke database/creds/db-read-only/&amp;lt;lease_id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After revocation, Vault immediately executes the &lt;code&gt;DROP USER&lt;/code&gt; statement on MariaDB — the credentials are invalidated instantly, even before the TTL would have expired.&lt;/p&gt;
&lt;h3&gt;Revoke All Leases for a Role&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;vault lease revoke -prefix database/creds/db-read-only/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is useful in incident response scenarios where you want to instantly invalidate every active credential for a compromised role.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Why This Matters: Security Benefits&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Traditional Approach&lt;/th&gt;
&lt;th&gt;Vault Dynamic Secrets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Static password stored in &lt;code&gt;.env&lt;/code&gt; or config&lt;/td&gt;
&lt;td&gt;No static password exists anywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Password shared across multiple services&lt;/td&gt;
&lt;td&gt;Each service/request gets a unique credential&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual rotation (easy to forget)&lt;/td&gt;
&lt;td&gt;Automatic revocation at TTL expiry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit trail unclear&lt;/td&gt;
&lt;td&gt;Every credential tied to a Vault lease ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Leaked password = permanent breach risk&lt;/td&gt;
&lt;td&gt;Leaked credential expires in 1 hour or less&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;What&apos;s Next&lt;/h2&gt;
&lt;p&gt;Once you&apos;ve verified the basic flow, consider exploring these advanced capabilities:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Root credential rotation&lt;/strong&gt; — Let Vault manage and rotate the &lt;code&gt;root&lt;/code&gt; password it uses:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vault write -force database/rotate-root/mariadb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After this command, only Vault knows the root password.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Static roles&lt;/strong&gt; — For existing long-lived database users that need periodic password rotation rather than dynamic creation.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Vault Agent&lt;/strong&gt; — A sidecar daemon that automatically fetches and renews credentials, injecting them into your application&apos;s environment or config files with zero code changes.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Vault API / SDK&lt;/strong&gt; — Integrate dynamic credential retrieval directly into your application at startup time using the &lt;a href=&quot;https://developer.hashicorp.com/vault/api-docs&quot;&gt;Vault HTTP API&lt;/a&gt; or language-specific SDKs (Go, Python, Java, etc.).&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;By integrating HashiCorp Vault&apos;s Database Secrets Engine with MariaDB, you fundamentally change how applications access your database:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No more static passwords&lt;/strong&gt; — credentials are generated on-demand and never stored in plaintext&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automatic isolation&lt;/strong&gt; — every consumer gets a unique credential, making audit logs precise and breach impact minimal&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Zero-config revocation&lt;/strong&gt; — credentials expire automatically; incident response is a single &lt;code&gt;vault lease revoke&lt;/code&gt; command away&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This pattern is the foundation of &lt;strong&gt;secrets management at scale&lt;/strong&gt; and applies equally well to PostgreSQL, MySQL, Oracle, MongoDB, and other supported database engines.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Reference:&lt;/strong&gt; &lt;a href=&quot;https://developer.hashicorp.com/vault/docs/secrets/databases/mysql-maria&quot;&gt;Vault MySQL/MariaDB Database Secrets Engine — HashiCorp Docs&lt;/a&gt;&lt;/p&gt;
</content:encoded></item><item><title>Zero Trust Kubernetes: Securing Cluster Access with Keycloak and Pinniped SSO</title><link>https://im-gatan.com/posts/zero-trust-kubernetes-keycloak-pinniped/</link><guid isPermaLink="true">https://im-gatan.com/posts/zero-trust-kubernetes-keycloak-pinniped/</guid><description>A comprehensive production-grade guide to implementing Zero Trust authentication in Kubernetes using Keycloak as an Identity Provider and Pinniped as an authentication broker, complete with group mapping, session lifecycle management, and troubleshooting.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This guide details a battle-tested setup for implementing &lt;strong&gt;Zero Trust Kubernetes authentication&lt;/strong&gt; in production — including common runtime pitfalls, configuration edge cases, and step-by-step troubleshooting solutions.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Background &amp;amp; Motivation&lt;/h2&gt;
&lt;p&gt;While many engineering teams run workloads on Kubernetes in production, cluster access is frequently managed using legacy authentication anti-patterns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Shared, static &lt;code&gt;kubeconfig&lt;/code&gt; files distributed via Slack, email, or internal repositories.&lt;/li&gt;
&lt;li&gt;Static ServiceAccount tokens that are never rotated.&lt;/li&gt;
&lt;li&gt;Over-privileged cluster admin roles with zero individual user audit logging.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this tutorial, we will set up &lt;strong&gt;Keycloak&lt;/strong&gt; as a centralized Identity Provider (IdP) and &lt;strong&gt;Pinniped&lt;/strong&gt; as the authentication bridge between Keycloak and your Kubernetes cluster.&lt;/p&gt;
&lt;h3&gt;Key Outcomes&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Self-service user login&lt;/strong&gt;: Developers authenticate directly using their single sign-on (SSO) credentials via a browser window.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Short-lived sessions&lt;/strong&gt;: User access automatically expires after 8 hours, eliminating static long-lived credentials.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Role-Based Access Control (RBAC)&lt;/strong&gt;: Permissions are dynamically mapped based on Keycloak user groups.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stateless &lt;code&gt;kubeconfig&lt;/code&gt;&lt;/strong&gt;: Distribute a single, static &lt;code&gt;kubeconfig&lt;/code&gt; template that contains zero credentials or secrets.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;Before starting, ensure you have the following prerequisites in place:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Kubernetes Cluster&lt;/strong&gt;: A running Kubernetes cluster (e.g., set up via &lt;code&gt;kubeadm&lt;/code&gt; or a managed provider).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keycloak Instance&lt;/strong&gt;: Keycloak running and accessible over HTTPS. If you don&apos;t have one set up yet, follow our guide on &lt;a href=&quot;/posts/deploy-keycloak-almalinux9/&quot;&gt;Deploying Keycloak 26 in Production on AlmaLinux 9&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ingress Controller&lt;/strong&gt;: Traefik installed as an Ingress Controller on your cluster. For installation details, see &lt;a href=&quot;/posts/argocd-traefik-https/&quot;&gt;Installing ArgoCD &amp;amp; Traefik HTTPS Ingress&lt;/a&gt; — you only need to complete the &lt;em&gt;Install Traefik via Helm&lt;/em&gt; section.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DNS Domain&lt;/strong&gt;: A domain pointing to your cluster Ingress IP for exposing the Pinniped Supervisor (e.g., &lt;code&gt;pinniped.example.com&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;Configuration Placeholders&lt;/h2&gt;
&lt;p&gt;The table below lists the placeholder variables used throughout this guide. Replace them with your environment&apos;s specific values:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Placeholder&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;KEYCLOAK_URL&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keycloak domain FQDN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sso.example.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;REALM_NAME&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keycloak realm name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;internal&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;PINNIPED_SUPERVISOR_URL&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;FQDN assigned to Pinniped Supervisor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pinniped.example.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;K8S_API_URL&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Kubernetes API Server endpoint and port&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://192.168.1.100:6443&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;YOUR_EMAIL&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ACME registration email for cert-manager&lt;/td&gt;
&lt;td&gt;&lt;code&gt;admin@example.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;USER_EMAIL&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Test developer email address&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dev-user@example.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Understanding Pinniped&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://pinniped.dev/&quot;&gt;Pinniped&lt;/a&gt; is a CNCF Sandbox project designed specifically to make Kubernetes authentication secure, seamless, and infrastructure-agnostic.&lt;/p&gt;
&lt;p&gt;Without Pinniped, every Kubernetes cluster must be individually configured with API server OIDC flags (&lt;code&gt;--oidc-issuer-url&lt;/code&gt;, &lt;code&gt;--oidc-client-id&lt;/code&gt;, etc.) — requiring control plane restarts and tricky multi-tenant maintenance. Pinniped solves this by acting as an intermediary authentication broker.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ Keycloak ]  (Identity Provider)
      │
      │  OIDC Authorization Code Flow
      ▼
[ Pinniped Supervisor ]  (Central Broker / OIDC IdP)
      │
      │  Cluster-scoped Token Exchange
      ▼
[ Pinniped Concierge ]   (Per-cluster Agent)
      │
      │  Impersonate User &amp;amp; Groups
      ▼
[ kube-apiserver ]       (Cluster Control Plane)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pinniped consists of two core components:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Supervisor&lt;/strong&gt;: Installed once (either on a dedicated management cluster or your main cluster). It acts as an OIDC identity broker, authenticating users against Keycloak and issuing cluster-scoped identity tokens.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Concierge&lt;/strong&gt;: Installed on every cluster you want to secure. It validates incoming tokens issued by the Supervisor and impersonates the identity when sending requests to &lt;code&gt;kube-apiserver&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;How Pinniped Kubeconfig Works&lt;/h3&gt;
&lt;p&gt;Traditional &lt;code&gt;kubeconfig&lt;/code&gt; files contain embedded client certificates or static tokens. Pinniped changes this paradigm entirely:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Credential-less configuration&lt;/strong&gt;: The generated &lt;code&gt;kubeconfig&lt;/code&gt; file contains no user credentials, certificates, or tokens.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Safe distribution&lt;/strong&gt;: The exact same &lt;code&gt;kubeconfig&lt;/code&gt; file can be safely stored in public or team internal Git repositories.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Browser-based flow&lt;/strong&gt;: When a user executes &lt;code&gt;kubectl&lt;/code&gt;, the Pinniped CLI plugin opens a local browser window to authenticate with Keycloak. Upon successful login, short-lived credentials are cached locally.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Architecture Overview&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;┌──────────────────────────────────────────────┐
│           Keycloak (Identity Provider)       │
└──────────────────────┬───────────────────────┘
                       │ OIDC Auth Code Flow
                       ▼
┌──────────────────────────────────────────────┐
│             Pinniped Supervisor              │  ← TLS via cert-manager
│  - FederationDomain                          │
│  - OIDCIdentityProvider                      │
└──────────────────────┬───────────────────────┘
                       │ Cluster-Scoped Token Exchange
                       ▼
┌──────────────────────────────────────────────┐
│              Pinniped Concierge              │  ← Deployed per downstream cluster
│  - JWTAuthenticator                          │
└──────────────────────┬───────────────────────┘
                       │ User Impersonation Header
                       ▼
┌──────────────────────────────────────────────┐
│                kube-apiserver                │
└──────────────────────┬───────────────────────┘
                       │ Native RBAC Evaluation
                       │ (Group Claim: keycloak:&amp;lt;group&amp;gt;)
         ┌─────────────┼─────────────┐
         │             │             │
         ▼             ▼             ▼
  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
  │  ns: dev    │ │  ns: prod   │ │ ns: staging │
  │ dev-team    │ │ ops-team    │ │ qa-team     │
  │ (Role: edit)│ │(Role: admin)│ │(Role: view) │
  └─────────────┘ └─────────────┘ └─────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;CNCF Projects Involved&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;CNCF Status&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;&lt;strong&gt;Keycloak&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Incubating&lt;/td&gt;
&lt;td&gt;Identity Provider (SSO &amp;amp; User Directory)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pinniped&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sandbox&lt;/td&gt;
&lt;td&gt;Authentication Broker &amp;amp; Credential Distribution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;cert-manager&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Graduated&lt;/td&gt;
&lt;td&gt;Automated TLS Certificate Management (Let&apos;s Encrypt)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Kubernetes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Graduated&lt;/td&gt;
&lt;td&gt;Container Orchestration Engine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Part 1: Keycloak Configuration&lt;/h2&gt;
&lt;h3&gt;1.1 Create the OIDC Client&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Log in to your Keycloak Admin Console and select your realm (&lt;code&gt;&amp;lt;REALM_NAME&amp;gt;&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Navigate to &lt;strong&gt;Clients&lt;/strong&gt; → click &lt;strong&gt;Create client&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Configure the client parameters:&lt;/li&gt;
&lt;/ol&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Client type&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OpenID Connect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Client ID&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubernetes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Client authentication&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Off (Public client)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Standard flow&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Valid redirect URIs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://localhost:8000&lt;/code&gt;, &lt;code&gt;http://localhost:18000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: In Part 3, once the Pinniped Supervisor endpoint is configured, you must return here and add &lt;code&gt;https://&amp;lt;PINNIPED_SUPERVISOR_URL&amp;gt;/*&lt;/code&gt; to &lt;strong&gt;Valid redirect URIs&lt;/strong&gt;. Failing to do so will result in an &lt;code&gt;Invalid parameter: redirect_uri&lt;/code&gt; error during browser authentication.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h3&gt;1.2 Add the Group Mapper&lt;/h3&gt;
&lt;p&gt;To enforce Kubernetes RBAC based on Keycloak user groups, the JWT token issued by Keycloak must contain a &lt;code&gt;groups&lt;/code&gt; claim.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Under the &lt;code&gt;kubernetes&lt;/code&gt; client, open the &lt;strong&gt;Client scopes&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Select the dedicated scope (e.g., &lt;code&gt;kubernetes-dedicated&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Add mapper&lt;/strong&gt; → &lt;strong&gt;By configuration&lt;/strong&gt; → &lt;strong&gt;Group Membership&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Set the following fields:&lt;/li&gt;
&lt;/ol&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Name&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;groups&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Token Claim Name&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;groups&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Full group path&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Add to ID token&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Add to access token&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Add to userinfo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important&lt;/strong&gt;: &lt;code&gt;groups&lt;/code&gt; here is a &lt;strong&gt;Token Claim Name&lt;/strong&gt;, not an OIDC scope name. If you attempt a manual OAuth request with &lt;code&gt;-d &quot;scope=openid groups&quot;&lt;/code&gt;, Keycloak will reject it with &lt;code&gt;invalid_scope&lt;/code&gt;. Always request &lt;code&gt;scope=openid&lt;/code&gt; — the &lt;code&gt;groups&lt;/code&gt; claim will automatically be included in the token payload once this mapper is enabled.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;You can verify the supported scopes on your realm endpoint:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -s https://&amp;lt;KEYCLOAK_URL&amp;gt;/realms/&amp;lt;REALM_NAME&amp;gt;/.well-known/openid-configuration | jq &apos;.scopes_supported&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;1.3 Create Groups and Assign Users&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;In Keycloak sidebar, go to &lt;strong&gt;Groups&lt;/strong&gt; and create:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;dev-team&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ops-team&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;qa-team&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Users&lt;/strong&gt;, select a user, open the &lt;strong&gt;Groups&lt;/strong&gt; tab, and click &lt;strong&gt;Join Group&lt;/strong&gt; to assign them to their respective team.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h3&gt;1.4 Test User Setup Checklist&lt;/h3&gt;
&lt;p&gt;When creating test users in Keycloak, ensure all required user details are configured. Incomplete profiles will cause authentication failures:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Consequence if omitted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;First Name&lt;/strong&gt; &amp;amp; &lt;strong&gt;Last Name&lt;/strong&gt; set&lt;/td&gt;
&lt;td&gt;Error: &lt;code&gt;Account is not fully set up&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Email verified&lt;/strong&gt; = On (Details tab)&lt;/td&gt;
&lt;td&gt;Error: &lt;code&gt;email_verified claim in upstream ID token has false value&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Password Temporary&lt;/strong&gt; = Off (Credentials tab)&lt;/td&gt;
&lt;td&gt;Error: &lt;code&gt;Account is not fully set up&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Required user actions&lt;/strong&gt; cleared&lt;/td&gt;
&lt;td&gt;Error: &lt;code&gt;Account is not fully set up&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip&lt;/strong&gt;: If Keycloak returns &lt;code&gt;Account is not fully set up&lt;/code&gt; despite meeting all criteria above, reset the user password manually under &lt;strong&gt;Credentials&lt;/strong&gt; → &lt;strong&gt;Set password&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h3&gt;1.5 Optional: Test Token Generation via Direct Access Grants&lt;/h3&gt;
&lt;p&gt;Direct Access Grants (password grant type) are disabled by default in modern Keycloak versions for security reasons. To temporarily test token issuance via &lt;code&gt;curl&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open Client &lt;code&gt;kubernetes&lt;/code&gt; → &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;Capability config&lt;/strong&gt; → enable &lt;strong&gt;Direct access grants&lt;/strong&gt; → Save.&lt;/li&gt;
&lt;li&gt;Execute the token request:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;curl -s -X POST \
  https://&amp;lt;KEYCLOAK_URL&amp;gt;/realms/&amp;lt;REALM_NAME&amp;gt;/protocol/openid-connect/token \
  -d &quot;client_id=kubernetes&quot; \
  -d &quot;username=&amp;lt;USER_EMAIL&amp;gt;&quot; \
  -d &quot;password=&amp;lt;PASSWORD&amp;gt;&quot; \
  -d &quot;grant_type=password&quot; \
  -d &quot;scope=openid&quot; \
  | jq -r &apos;.id_token&apos; \
  | cut -d. -f2 \
  | base64 -d 2&amp;gt;/dev/null \
  | jq .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify that the &lt;code&gt;groups&lt;/code&gt; array is present in the decoded token payload:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;email&quot;: &quot;dev-user@example.com&quot;,
  &quot;email_verified&quot;: true,
  &quot;groups&quot;: [&quot;dev-team&quot;],
  &quot;iss&quot;: &quot;https://sso.example.com/realms/internal&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Security Note&lt;/strong&gt;: Disable &lt;strong&gt;Direct access grants&lt;/strong&gt; after testing. Production authorization always uses the standard Authorization Code Flow via browser.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h3&gt;1.6 Configure 8-Hour Session Lifespan&lt;/h3&gt;
&lt;p&gt;In Keycloak Admin Console, open &lt;strong&gt;Realm Settings&lt;/strong&gt; → &lt;strong&gt;Sessions&lt;/strong&gt; tab:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Lifespan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSO Session Idle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8 hours&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSO Session Max&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8 hours&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Client Session Idle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8 hours&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Client Session Max&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;8 hours&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Next, select the &lt;strong&gt;Tokens&lt;/strong&gt; tab:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Lifespan&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Access Token Lifespan&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;5 minutes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Refresh Token Max Reuse&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;When Keycloak&apos;s refresh token expires after 8 hours, Pinniped automatically prompts the user to re-authenticate via their browser on the next &lt;code&gt;kubectl&lt;/code&gt; command execution.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Part 2: Installing cert-manager for Pinniped TLS&lt;/h2&gt;
&lt;p&gt;Pinniped Supervisor requires a trusted TLS certificate for its endpoint. By default, Pinniped generates a self-signed bootstrap certificate that external CLI clients will reject with:
&lt;code&gt;tls: failed to verify certificate: x509: certificate is valid for pinniped-supervisor-bootstrap-cert&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;We install &lt;strong&gt;cert-manager&lt;/strong&gt; to automatically obtain and renew Let&apos;s Encrypt TLS certificates as Kubernetes secrets.&lt;/p&gt;
&lt;h3&gt;2.1 Install cert-manager&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml

# Monitor deployment status until all pods are Ready
kubectl get pods -n cert-manager --watch
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.2 Define ClusterIssuer and Certificate&lt;/h3&gt;
&lt;p&gt;Create &lt;code&gt;cert-manager-pinniped.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: &amp;lt;YOUR_EMAIL&amp;gt;
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik

---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: pinniped-tls
  namespace: pinniped-supervisor
spec:
  secretName: pinniped-tls-cert
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - &amp;lt;PINNIPED_SUPERVISOR_URL&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apply the configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f cert-manager-pinniped.yaml

# Verify Certificate status (Wait for READY: True)
kubectl get certificate pinniped-tls -n pinniped-supervisor --watch
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Part 3: Deploying Pinniped Supervisor&lt;/h2&gt;
&lt;h3&gt;3.1 Install Pinniped Supervisor&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f https://get.pinniped.dev/latest/install-pinniped-supervisor.yaml

# Verify pods
kubectl get pods -n pinniped-supervisor
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.2 Expose Supervisor via Traefik (TLS Passthrough)&lt;/h3&gt;
&lt;p&gt;Because Pinniped Supervisor directly handles sensitive credential exchanges, it requires &lt;strong&gt;TLS Passthrough&lt;/strong&gt; to perform end-to-end TLS termination directly inside the pod rather than at the Ingress controller layer.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Create a &lt;code&gt;ClusterIP&lt;/code&gt; Service for the Supervisor:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;# pinniped-supervisor-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: pinniped-supervisor
  namespace: pinniped-supervisor
spec:
  type: ClusterIP
  selector:
    app: pinniped-supervisor
  ports:
    - protocol: TCP
      port: 443
      targetPort: 8443
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Create a Traefik &lt;code&gt;IngressRouteTCP&lt;/code&gt; with &lt;code&gt;passthrough: true&lt;/code&gt;:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;# pinniped-supervisor-ingressroute.yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
  name: pinniped-supervisor
  namespace: pinniped-supervisor
spec:
  entryPoints:
    - websecure
  routes:
    - match: HostSNI(`&amp;lt;PINNIPED_SUPERVISOR_URL&amp;gt;`)
      services:
        - name: pinniped-supervisor
          port: 443
  tls:
    passthrough: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apply both manifests:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f pinniped-supervisor-service.yaml
kubectl apply -f pinniped-supervisor-ingressroute.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;3.3 Configure OIDCIdentityProvider&lt;/h3&gt;
&lt;p&gt;Create the custom &lt;code&gt;OIDCIdentityProvider&lt;/code&gt; resource to link Pinniped Supervisor to Keycloak:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: idp.supervisor.pinniped.dev/v1alpha1
kind: OIDCIdentityProvider
metadata:
  name: keycloak-idp
  namespace: pinniped-supervisor
spec:
  issuer: https://&amp;lt;KEYCLOAK_URL&amp;gt;/realms/&amp;lt;REALM_NAME&amp;gt;
  authorizationConfig:
    additionalScopes:
      - email
    allowPasswordGrant: false
  claims:
    username: email
    groups: groups
  client:
    secretName: keycloak-client-secret
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: Do not add &lt;code&gt;groups&lt;/code&gt; to &lt;code&gt;additionalScopes&lt;/code&gt;. Requesting &lt;code&gt;groups&lt;/code&gt; as an OIDC scope will trigger an &lt;code&gt;invalid_scope&lt;/code&gt; error from Keycloak.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Now, create the corresponding client secret. Pinniped requires a custom secret type &lt;code&gt;secrets.pinniped.dev/oidc-client&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: v1
kind: Secret
metadata:
  name: keycloak-client-secret
  namespace: pinniped-supervisor
type: secrets.pinniped.dev/oidc-client
stringData:
  clientID: kubernetes
  clientSecret: &quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Caution&lt;/strong&gt;: Do &lt;strong&gt;not&lt;/strong&gt; use &lt;code&gt;kubectl create secret generic&lt;/code&gt;. Generic secrets have type &lt;code&gt;Opaque&lt;/code&gt;, which will cause Pinniped to throw the error &lt;code&gt;referenced Secret has wrong type &quot;Opaque&quot;&lt;/code&gt;. Always apply via YAML with &lt;code&gt;type: secrets.pinniped.dev/oidc-client&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Verify IdP health:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl describe OIDCIdentityProvider keycloak-idp -n pinniped-supervisor
# Look for: Phase: Ready
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If status remains in &lt;code&gt;Phase: Error&lt;/code&gt;, restart the Supervisor deployment to reload secrets:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl rollout restart deployment/pinniped-supervisor -n pinniped-supervisor
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;3.4 Configure FederationDomain&lt;/h3&gt;
&lt;p&gt;The &lt;code&gt;FederationDomain&lt;/code&gt; exposes the public OIDC issuer endpoint of the Supervisor, using the TLS certificate generated by cert-manager:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: config.supervisor.pinniped.dev/v1alpha1
kind: FederationDomain
metadata:
  name: kubernetes-federation
  namespace: pinniped-supervisor
spec:
  issuer: https://&amp;lt;PINNIPED_SUPERVISOR_URL&amp;gt;/kubernetes-federation
  tls:
    secretName: pinniped-tls-cert
  identityProviders:
    - displayName: &quot;Keycloak SSO&quot;
      objectRef:
        apiGroup: idp.supervisor.pinniped.dev
        kind: OIDCIdentityProvider
        name: keycloak-idp
      transforms:
        expressions:
          - type: username/v1
            expression: &apos;&quot;keycloak:&quot; + username&apos;
          - type: groups/v1
            expression: &apos;groups.map(g, &quot;keycloak:&quot; + g)&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify FederationDomain status:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl describe FederationDomain kubernetes-federation -n pinniped-supervisor | grep &quot;Phase\|Message&quot;
# Expected output: Phase: Ready
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Remember to update Keycloak client settings (&lt;strong&gt;Part 1.1&lt;/strong&gt;) by adding &lt;code&gt;https://&amp;lt;PINNIPED_SUPERVISOR_URL&amp;gt;/*&lt;/code&gt; to the client&apos;s &lt;strong&gt;Valid redirect URIs&lt;/strong&gt;.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Part 4: Deploying Pinniped Concierge&lt;/h2&gt;
&lt;h3&gt;4.1 Install Concierge&lt;/h3&gt;
&lt;p&gt;Deploy the Pinniped Concierge agent on the target Kubernetes cluster:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f https://get.pinniped.dev/latest/install-pinniped-concierge.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.2 Configure JWTAuthenticator&lt;/h3&gt;
&lt;p&gt;The Concierge uses a &lt;code&gt;JWTAuthenticator&lt;/code&gt; to validate authentication tokens issued by the Supervisor.&lt;/p&gt;
&lt;p&gt;Extract the active TLS Certificate Authority bundle from &lt;code&gt;pinniped-tls-cert&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;LE_CA=$(kubectl get secret pinniped-tls-cert \
  -n pinniped-supervisor \
  -o jsonpath=&apos;{.data.tls\.crt}&apos;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apply the &lt;code&gt;JWTAuthenticator&lt;/code&gt; resource:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f - &amp;lt;&amp;lt;EOF
apiVersion: authentication.concierge.pinniped.dev/v1alpha1
kind: JWTAuthenticator
metadata:
  name: supervisor-jwt-authenticator
  namespace: pinniped-concierge
spec:
  issuer: https://&amp;lt;PINNIPED_SUPERVISOR_URL&amp;gt;/kubernetes-federation
  audience: https://&amp;lt;K8S_API_URL&amp;gt;
  tls:
    certificateAuthorityData: ${LE_CA}
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify Authenticator status:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl describe JWTAuthenticator supervisor-jwt-authenticator -n pinniped-concierge | grep &quot;Phase\|Message&quot;
# Expected output: Phase: Ready
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Part 5: Kubeconfig Generation &amp;amp; Developer Workflow&lt;/h2&gt;
&lt;h3&gt;5.1 Install Pinniped CLI&lt;/h3&gt;
&lt;p&gt;Install the &lt;code&gt;pinniped&lt;/code&gt; CLI on your workstation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# macOS via Homebrew
brew install pinniped-cli

# Linux Binary
curl -Lo pinniped https://get.pinniped.dev/latest/pinniped-cli-linux-amd64
chmod +x pinniped
sudo mv pinniped /usr/local/bin/pinniped
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;5.2 Generate Kubeconfig File&lt;/h3&gt;
&lt;p&gt;As an administrator, generate the static &lt;code&gt;kubeconfig&lt;/code&gt; template:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pinniped get kubeconfig \
  --kubeconfig ~/.kube/config \
  &amp;gt; kubeconfig-production.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: In Pinniped CLI v0.47.0+, the &lt;code&gt;--pinniped-namespace&lt;/code&gt; flag was deprecated in favor of automatic discovery.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This &lt;code&gt;kubeconfig-production.yaml&lt;/code&gt; file contains &lt;strong&gt;no user secrets&lt;/strong&gt;. Upload it to your team&apos;s internal documentation site or repository.&lt;/p&gt;
&lt;hr /&gt;
&lt;h3&gt;5.3 Workstation Setup for Developers&lt;/h3&gt;
&lt;p&gt;Developers set up cluster access on their machines in two steps:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 1. Download generic kubeconfig from internal docs
curl -O https://internal-docs.example.com/kubernetes/kubeconfig-production.yaml

# 2. Install Pinniped CLI
brew install pinniped-cli
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Path Troubleshooting&lt;/strong&gt;: The generated &lt;code&gt;kubeconfig&lt;/code&gt; invokes &lt;code&gt;/usr/local/bin/pinniped&lt;/code&gt;. On Apple Silicon Macs, Homebrew installs binaries under &lt;code&gt;/opt/homebrew/bin/pinniped&lt;/code&gt;. Create a symbolic link to prevent &lt;code&gt;exec: fork/exec /usr/local/bin/pinniped: no such file or directory&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo ln -s $(which pinniped) /usr/local/bin/pinniped
&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h3&gt;5.4 First-Time Login Experience&lt;/h3&gt;
&lt;p&gt;When a developer runs &lt;code&gt;kubectl&lt;/code&gt; using the Pinniped &lt;code&gt;kubeconfig&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export KUBECONFIG=./kubeconfig-production.yaml
kubectl get pod -n dev
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;Pinniped automatically launches the system web browser pointing to Keycloak SSO.&lt;/li&gt;
&lt;li&gt;The user enters their Keycloak credentials.&lt;/li&gt;
&lt;li&gt;The browser displays &lt;strong&gt;&quot;Login Succeeded&quot;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Control returns to the terminal, and &lt;code&gt;kubectl&lt;/code&gt; executes seamlessly.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h3&gt;5.5 Daily Session Flow&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;Morning (08:00):
  kubectl get pods ──▶ Browser opens ──▶ User authenticates
  └── Session active for 8 hours ✅

Afternoon (12:00):
  kubectl get pods ──▶ Seamless background token refresh ✅

End of Shift / Next Day (After 8 Hours):
  kubectl get pods ──▶ Refresh token expired
  └── Browser opens automatically for standard re-login ✅
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To manually clear session credentials (e.g., when testing different users or groups):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Linux
rm -rf ~/.config/pinniped/sessions.yaml

# macOS
rm -rf ~/Library/Application\ Support/pinniped/sessions.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Part 6: Multi-Tenant RBAC &amp;amp; Workload Setup&lt;/h2&gt;
&lt;p&gt;Apply namespaces, RBAC bindings, and test pods across development, production, and staging environments:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 06-rbac-and-pods.yaml
---
# NAMESPACE: dev — Granted to keycloak:dev-team (Role: edit)
apiVersion: v1
kind: Namespace
metadata:
  name: dev
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: dev-team-edit
  namespace: dev
subjects:
  - kind: Group
    name: &quot;keycloak:dev-team&quot;
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: edit
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Pod
metadata:
  name: demo-frontend
  namespace: dev
spec:
  containers:
  - name: frontend
    image: nginx:alpine
---
apiVersion: v1
kind: Pod
metadata:
  name: demo-backend
  namespace: dev
spec:
  containers:
  - name: backend
    image: hashicorp/http-echo:alpine
    args: [&quot;-text=Backend Service running in DEV&quot;]
---
apiVersion: v1
kind: Pod
metadata:
  name: demo-database
  namespace: dev
spec:
  containers:
  - name: database
    image: postgres:15-alpine
    env:
    - name: POSTGRES_PASSWORD
      value: &quot;devpassword&quot;

---
# NAMESPACE: prod — Granted to keycloak:ops-team (Role: admin)
apiVersion: v1
kind: Namespace
metadata:
  name: prod
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ops-team-admin
  namespace: prod
subjects:
  - kind: Group
    name: &quot;keycloak:ops-team&quot;
    apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: admin
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Pod
metadata:
  name: demo-payment
  namespace: prod
spec:
  containers:
  - name: payment
    image: hashicorp/http-echo:alpine
    args: [&quot;-text=Payment Service in PROD&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apply the resources:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f 06-rbac-and-pods.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Part 7: End-to-End Verification &amp;amp; CLI Testing&lt;/h2&gt;
&lt;h3&gt;7.1 Verify Active User Identity&lt;/h3&gt;
&lt;p&gt;Run &lt;code&gt;kubectl auth whoami&lt;/code&gt; to verify how the API server perceives your authenticated identity and groups:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ kubectl auth whoami
ATTRIBUTE                                           VALUE
Username                                            keycloak:dev-user@example.com
Groups                                              [keycloak:dev-team system:authenticated]
Extra: authentication.kubernetes.io/credential-id   [X509SHA256=e5cfbbef0c518879defcaa995b2a4e578522c68d691d1ae9f910e759b5b7c5b7]
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h3&gt;7.2 Access Control Verification&lt;/h3&gt;
&lt;p&gt;Test resource listing under allowed versus restricted namespaces:&lt;/p&gt;
&lt;h4&gt;Allowed Access (&lt;code&gt;dev&lt;/code&gt; namespace)&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;$ kubectl get pod -n dev
NAME            READY   STATUS    RESTARTS   AGE
demo-backend    1/1     Running   0          9h
demo-database   1/1     Running   0          9h
demo-frontend   1/1     Running   0          9h
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Forbidden Access (&lt;code&gt;prod&lt;/code&gt; namespace for &lt;code&gt;dev-team&lt;/code&gt; member)&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;$ kubectl get pod -n prod
Error from server (Forbidden): pods is forbidden: User &quot;keycloak:dev-user@example.com&quot; cannot list resource &quot;pods&quot; in API group &quot;&quot; in the namespace &quot;prod&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;Pre-flight Permission Check&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;$ kubectl auth can-i create deployments -n prod
no
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Troubleshooting Guide&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom / Error&lt;/th&gt;
&lt;th&gt;Root Cause&lt;/th&gt;
&lt;th&gt;Resolution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unauthorized_client: Client not allowed for direct access grants&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Direct access grants disabled in Keycloak settings&lt;/td&gt;
&lt;td&gt;Enable temporarily under Client Settings tab if testing via &lt;code&gt;curl&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invalid_scope: Invalid scopes: openid groups&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;groups&lt;/code&gt; passed as scope parameter&lt;/td&gt;
&lt;td&gt;Omit &lt;code&gt;groups&lt;/code&gt; from scope argument; standard &lt;code&gt;scope=openid&lt;/code&gt; includes claim.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Account is not fully set up&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Missing user profile data&lt;/td&gt;
&lt;td&gt;Ensure First Name, Last Name, Email Verified=True, and clear temporary passwords.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Secret has wrong type &quot;Opaque&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Created via &lt;code&gt;kubectl create secret generic&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Apply via YAML setting &lt;code&gt;type: secrets.pinniped.dev/oidc-client&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tls: certificate is valid for pinniped-supervisor-bootstrap-cert&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Using self-signed Supervisor certificate&lt;/td&gt;
&lt;td&gt;Deploy cert-manager Certificate and reference TLS secret in &lt;code&gt;FederationDomain&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Invalid parameter: redirect_uri&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Callback URL missing in Keycloak&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;https://&amp;lt;PINNIPED_SUPERVISOR_URL&amp;gt;/*&lt;/code&gt; to Valid Redirect URIs in Keycloak.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;email_verified claim in upstream ID token has false value&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Unverified user email in Keycloak&lt;/td&gt;
&lt;td&gt;Toggle &lt;strong&gt;Email Verified&lt;/strong&gt; to &lt;code&gt;On&lt;/code&gt; under Keycloak User details.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fork/exec /usr/local/bin/pinniped: no such file or directory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CLI installed outside standard path&lt;/td&gt;
&lt;td&gt;Symlink binary: &lt;code&gt;sudo ln -s $(which pinniped) /usr/local/bin/pinniped&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;could not complete Concierge credential exchange: authentication failed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Outdated TLS CA in Concierge&lt;/td&gt;
&lt;td&gt;Re-extract &lt;code&gt;tls.crt&lt;/code&gt; from &lt;code&gt;pinniped-tls-cert&lt;/code&gt; and update &lt;code&gt;JWTAuthenticator&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Error: unknown flag: --pinniped-namespace&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Deprecated CLI flag&lt;/td&gt;
&lt;td&gt;Remove flag; Pinniped CLI auto-detects Concierge namespace in modern versions.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Production Lessons Learned&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Group Mapper is Critical&lt;/strong&gt;: Without adding the &lt;code&gt;groups&lt;/code&gt; mapper under Client Scopes, authentication completes but RBAC bindings continuously fail.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Treat &lt;code&gt;groups&lt;/code&gt; as Claims, Not Scopes&lt;/strong&gt;: Passing &lt;code&gt;groups&lt;/code&gt; as an OIDC scope parameter breaks Keycloak protocol validation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pinniped Supervisor Requires Valid Public TLS&lt;/strong&gt;: Never expose Supervisor using self-signed bootstrap certs in production environments.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stateless Kubeconfig Security&lt;/strong&gt;: Since Pinniped &lt;code&gt;kubeconfig&lt;/code&gt; files contain no secrets, credential leaks via repository commits are completely mitigated. Access revocation is performed instantly in Keycloak.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;Architecture Summary&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Legacy Kubeconfig&lt;/th&gt;
&lt;th&gt;Pinniped + Keycloak Zero Trust&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Credentials&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Static embedded tokens / certs&lt;/td&gt;
&lt;td&gt;Short-lived OIDC tokens (8h max)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;User Identification&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Generic / Shared credentials&lt;/td&gt;
&lt;td&gt;Per-user identity and group claims&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Audit Trail&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Anonymous / Shared admin logs&lt;/td&gt;
&lt;td&gt;Distinct per-user audit trails in API server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Access Revocation&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Manual cluster cert rotation&lt;/td&gt;
&lt;td&gt;Single-click disable in Keycloak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Developer Onboarding&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Admin generates custom &lt;code&gt;kubeconfig&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Self-service download of static template&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Official Documentation &amp;amp; References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://pinniped.dev/docs/&quot;&gt;Pinniped Official Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://pinniped.dev/docs/reference/tokens-and-credentials/&quot;&gt;Pinniped Tokens and Credentials Architecture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cert-manager.io/docs/&quot;&gt;cert-manager Official Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens&quot;&gt;Kubernetes OpenID Connect Tokens Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/deploy-keycloak-almalinux9/&quot;&gt;Deploying Keycloak 26 on AlmaLinux 9&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/argocd-traefik-https/&quot;&gt;Installing ArgoCD &amp;amp; Traefik HTTPS Ingress&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>Integrating Keycloak with GitLab — Single Sign-On via OpenID Connect</title><link>https://im-gatan.com/posts/keycloak-gitlab-integration/</link><guid isPermaLink="true">https://im-gatan.com/posts/keycloak-gitlab-integration/</guid><description>A step-by-step guide to integrating GitLab Self-Managed (Omnibus) with Keycloak as the Identity Provider using OpenID Connect (OIDC), enabling SSO login, automatic account provisioning, and auto-linking existing accounts by email.</description><pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This guide walks you through integrating &lt;strong&gt;GitLab Self-Managed (Omnibus)&lt;/strong&gt; with &lt;strong&gt;Keycloak&lt;/strong&gt; as a Single Sign-On (SSO) Identity Provider using the &lt;strong&gt;OpenID Connect (OIDC)&lt;/strong&gt; protocol — so your users can log in to GitLab through Keycloak.&lt;/p&gt;
&lt;p&gt;This document focuses purely on the integration itself. For the Keycloak installation, refer to the separate guide: &lt;a href=&quot;/posts/deploy-keycloak-almalinux9/&quot;&gt;Deploying Keycloak 26 in Production on AlmaLinux 9&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Placeholders used throughout this guide:&lt;/strong&gt;
Wherever you see values like &lt;code&gt;sso.example.com&lt;/code&gt;, &lt;code&gt;gitlab.example.com&lt;/code&gt;, or &lt;code&gt;YOUR_CLIENT_SECRET&lt;/code&gt;, replace them with your own actual values.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Placeholder&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sso.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your Keycloak FQDN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sso.company.net&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gitlab.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your GitLab FQDN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gitlab.company.net&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;YOUR_CLIENT_SECRET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The OIDC client secret from Keycloak&lt;/td&gt;
&lt;td&gt;Copy from the Credentials tab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;YOUR_REALM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your Keycloak realm name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;internal&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Table of Contents&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;#1-prerequisites&quot;&gt;Prerequisites&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-create-a-realm-in-keycloak&quot;&gt;Create a Realm in Keycloak&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-create-a-client-for-gitlab&quot;&gt;Create a Client for GitLab&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#4-retrieve-the-client-secret&quot;&gt;Retrieve the Client Secret&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#5-verify-the-realm-signature-algorithm&quot;&gt;Verify the Realm Signature Algorithm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#6-configure-gitlab-gitlabrb&quot;&gt;Configure GitLab (gitlab.rb)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#7-auto-link-existing-accounts-by-email&quot;&gt;Auto-Link Existing Accounts by Email&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#8-reconfigure--test-login&quot;&gt;Reconfigure &amp;amp; Test Login&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#9-troubleshooting&quot;&gt;Troubleshooting&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;1. Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Keycloak&lt;/strong&gt; is up and running, accessible over HTTPS (e.g., &lt;code&gt;https://sso.example.com&lt;/code&gt;). &lt;strong&gt;GitLab requires OIDC Identity Providers to be accessed over HTTPS&lt;/strong&gt; — plain HTTP is not supported.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GitLab Omnibus (Self-Managed)&lt;/strong&gt; is installed and accessible over HTTPS (e.g., &lt;code&gt;https://gitlab.example.com&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Admin access&lt;/strong&gt; to the Keycloak Admin Console.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Root/sudo access&lt;/strong&gt; to the GitLab server for editing &lt;code&gt;/etc/gitlab/gitlab.rb&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;2. Create a Realm in Keycloak&lt;/h2&gt;
&lt;p&gt;Production application clients should not be placed in the &lt;code&gt;master&lt;/code&gt; realm — that realm is recommended only for Keycloak&apos;s own superadmin accounts.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Log in to &lt;code&gt;https://sso.example.com/admin/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Click the realm dropdown in the top-left corner → &lt;strong&gt;Create realm&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Enter a &lt;strong&gt;Realm name&lt;/strong&gt;, for example &lt;code&gt;YOUR_REALM&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Create&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;All subsequent steps are performed inside this realm.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!NOTE]
If you already have a realm set up from a previous integration (e.g., ArgoCD), you can reuse it. There is no need to create a new realm for each application — just create a new client within the existing realm.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;3. Create a Client for GitLab&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Inside your realm, navigate to &lt;strong&gt;Clients&lt;/strong&gt; → &lt;strong&gt;Create client&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;General Settings&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;Client type: &lt;code&gt;OpenID Connect&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Client ID: &lt;code&gt;gitlab&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Next&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Capability config&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Client authentication&lt;/strong&gt;: &lt;code&gt;On&lt;/code&gt; (this makes the client confidential, generating a client secret)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Authentication flow&lt;/strong&gt;: check &lt;strong&gt;Standard flow&lt;/strong&gt; only. Leave &lt;strong&gt;Direct access grants&lt;/strong&gt;, &lt;strong&gt;Implicit flow&lt;/strong&gt;, and other options unchecked — Standard flow (Authorization Code) is sufficient and the most secure choice for this use case.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Next&lt;/strong&gt;, then &lt;strong&gt;Save&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;On the &lt;strong&gt;Settings&lt;/strong&gt; page of the newly created client:
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Valid redirect URIs&lt;/strong&gt;: &lt;code&gt;https://gitlab.example.com/users/auth/openid_connect/callback&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Valid post logout redirect URIs&lt;/strong&gt;: &lt;code&gt;https://gitlab.example.com/*&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Web origins&lt;/strong&gt;: &lt;code&gt;https://gitlab.example.com&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;4. Retrieve the Client Secret&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Still on the &lt;code&gt;gitlab&lt;/code&gt; client, open the &lt;strong&gt;Credentials&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Copy the &lt;strong&gt;Client secret&lt;/strong&gt; value — this will be used in the &lt;code&gt;gitlab.rb&lt;/code&gt; configuration.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;5. Verify the Realm Signature Algorithm&lt;/h2&gt;
&lt;p&gt;GitLab recommends tokens to be signed with a public-key algorithm (RS256) rather than a symmetric key (HS256), as it is more secure and easier to configure.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Navigate to &lt;strong&gt;Realm settings&lt;/strong&gt; → &lt;strong&gt;Tokens&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Check the &lt;strong&gt;Default signature algorithm&lt;/strong&gt; — ensure it is set to &lt;code&gt;RS256&lt;/code&gt; (this is Keycloak&apos;s default and typically does not need to be changed).&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;6. Configure GitLab (gitlab.rb)&lt;/h2&gt;
&lt;p&gt;Edit the GitLab configuration file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo nano /etc/gitlab/gitlab.rb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add the following block (replace &lt;code&gt;identifier&lt;/code&gt; and &lt;code&gt;secret&lt;/code&gt; with the Client ID and Client Secret from steps 3–4):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gitlab_rails[&apos;omniauth_allow_single_sign_on&apos;] = [&apos;openid_connect&apos;]
gitlab_rails[&apos;omniauth_block_auto_created_users&apos;] = false

gitlab_rails[&apos;omniauth_providers&apos;] = [
  {
    name: &quot;openid_connect&quot;, # do not change — this is the internal provider name
    label: &quot;Keycloak&quot;,      # the label displayed on the login button
    args: {
      name: &quot;openid_connect&quot;,
      scope: [&quot;openid&quot;, &quot;profile&quot;, &quot;email&quot;],
      response_type: &quot;code&quot;,
      issuer: &quot;https://sso.example.com/realms/YOUR_REALM&quot;,
      client_auth_method: &quot;query&quot;,
      discovery: true,
      uid_field: &quot;preferred_username&quot;,
      pkce: true,
      client_options: {
        identifier: &quot;gitlab&quot;,
        secret: &quot;YOUR_CLIENT_SECRET&quot;,
        redirect_uri: &quot;https://gitlab.example.com/users/auth/openid_connect/callback&quot;
      }
    }
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Key options explained:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;omniauth_allow_single_sign_on&lt;/code&gt;&lt;/strong&gt; — enables automatic creation of new GitLab accounts (Just-In-Time provisioning) when a user logs in through Keycloak for the first time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;omniauth_block_auto_created_users&lt;/code&gt;&lt;/strong&gt; — when &lt;code&gt;false&lt;/code&gt;, automatically created accounts are immediately active without requiring admin approval. Set to &lt;code&gt;true&lt;/code&gt; if you want a GitLab admin to manually approve every new SSO account.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;discovery: true&lt;/code&gt;&lt;/strong&gt; — GitLab automatically fetches all OIDC endpoints (authorization, token, userinfo, jwks) from &lt;code&gt;https://sso.example.com/realms/YOUR_REALM/.well-known/openid-configuration&lt;/code&gt;, so there is no need to define them individually.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;pkce: true&lt;/code&gt;&lt;/strong&gt; — enables PKCE (Proof Key for Code Exchange) as an additional security layer on the Authorization Code flow.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;uid_field: &quot;preferred_username&quot;&lt;/code&gt;&lt;/strong&gt; — the field from the Keycloak token that GitLab uses as the unique user identifier.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;7. Auto-Link Existing Accounts by Email&lt;/h2&gt;
&lt;p&gt;If there are existing GitLab users whose email addresses match those that will be used for Keycloak login, add the following line so that GitLab links to the existing account instead of creating a new one:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gitlab_rails[&apos;omniauth_auto_link_user&apos;] = [&quot;openid_connect&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Auto-linking matches based on email addresses that are &lt;strong&gt;verified on both sides&lt;/strong&gt;. Without this line, a user with a matching email will still receive a separate, brand-new GitLab account.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;8. Reconfigure &amp;amp; Test Login&lt;/h2&gt;
&lt;p&gt;Apply the configuration changes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo gitlab-ctl reconfigure
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once complete, open the GitLab login page:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://gitlab.example.com/users/sign_in
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A login button labeled &lt;strong&gt;Keycloak&lt;/strong&gt; will appear below the standard login form. Click it to be redirected to the Keycloak login page, and after successful authentication, you will be redirected back to GitLab in a logged-in state.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!IMPORTANT]
After SSO is enabled, every sign-in attempt is redirected to the OmniAuth provider, which means the standard local GitLab login will no longer be directly available from the main page. Make sure at least one OmniAuth user has administrator privileges before enabling this in production. Local login can still be accessed manually via &lt;code&gt;https://gitlab.example.com/users/sign_in?auto_sign_in=false&lt;/code&gt; as an emergency fallback.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;9. Troubleshooting&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely Cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Keycloak login button does not appear&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gitlab-ctl reconfigure&lt;/code&gt; has not been run, or there is a syntax error in &lt;code&gt;gitlab.rb&lt;/code&gt; — run &lt;code&gt;sudo gitlab-ctl reconfigure&lt;/code&gt; and check the output for errors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invalid_redirect_uri&lt;/code&gt; error from Keycloak&lt;/td&gt;
&lt;td&gt;The redirect URI in the Keycloak client does not exactly match the one sent by GitLab (&lt;code&gt;https://gitlab.example.com/users/auth/openid_connect/callback&lt;/code&gt;) — pay attention to trailing slashes and the http/https scheme&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery failure / GitLab cannot fetch OIDC configuration&lt;/td&gt;
&lt;td&gt;Ensure the GitLab server can resolve DNS and make HTTPS requests to &lt;code&gt;sso.example.com&lt;/code&gt;; test with &lt;code&gt;curl https://sso.example.com/realms/YOUR_REALM/.well-known/openid-configuration&lt;/code&gt; from the GitLab server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login succeeds at Keycloak but fails returning to GitLab / signature invalid&lt;/td&gt;
&lt;td&gt;Check that &lt;strong&gt;Default signature algorithm&lt;/strong&gt; in Realm Settings &amp;gt; Tokens is set to RS256, not HS256&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same user logging in via SSO creates a new account instead of linking to the existing one&lt;/td&gt;
&lt;td&gt;Add &lt;code&gt;gitlab_rails[&apos;omniauth_auto_link_user&apos;] = [&quot;openid_connect&quot;]&lt;/code&gt; as described in step 7, and ensure the email is identical and verified on both sides&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitLab refuses connection to Keycloak citing HTTPS&lt;/td&gt;
&lt;td&gt;GitLab does not support OIDC Identity Providers over plain HTTP; ensure Keycloak is accessible over HTTPS (see the Keycloak deployment guide)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;Summary of the flow:&lt;/strong&gt; create a dedicated realm in Keycloak (or reuse an existing one) → register a &lt;code&gt;gitlab&lt;/code&gt; client with Standard Flow only → copy the client secret → add the configuration to &lt;code&gt;gitlab.rb&lt;/code&gt; with the matching &lt;code&gt;issuer&lt;/code&gt;, &lt;code&gt;identifier&lt;/code&gt;, and &lt;code&gt;secret&lt;/code&gt; → run &lt;code&gt;gitlab-ctl reconfigure&lt;/code&gt; → test login via the Keycloak button on the GitLab sign-in page.&lt;/p&gt;
</content:encoded></item><item><title>Integrating Keycloak with ArgoCD — Single Sign-On via OpenID Connect</title><link>https://im-gatan.com/posts/keycloak-argocd-integration/</link><guid isPermaLink="true">https://im-gatan.com/posts/keycloak-argocd-integration/</guid><description>A step-by-step guide to integrating ArgoCD running on Kubernetes with Keycloak as the Identity Provider using native OIDC support (without Dex), including mapping Keycloak groups to ArgoCD RBAC roles.</description><pubDate>Sun, 19 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This guide walks you through integrating &lt;strong&gt;ArgoCD&lt;/strong&gt; with &lt;strong&gt;Keycloak&lt;/strong&gt; as a Single Sign-On (SSO) Identity Provider using ArgoCD&apos;s native OpenID Connect (OIDC) support — no Dex required. Along the way, we&apos;ll map &lt;strong&gt;Keycloak groups&lt;/strong&gt; to &lt;strong&gt;ArgoCD RBAC roles&lt;/strong&gt; for fine-grained access control.&lt;/p&gt;
&lt;p&gt;This document focuses purely on the integration itself. For the Keycloak installation, refer to the separate guide: &lt;a href=&quot;/posts/deploy-keycloak-almalinux9/&quot;&gt;Deploying Keycloak 26 in Production on AlmaLinux 9&lt;/a&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Placeholders used throughout this guide:&lt;/strong&gt;
Wherever you see values like &lt;code&gt;sso.example.com&lt;/code&gt;, &lt;code&gt;argocd.example.com&lt;/code&gt;, or &lt;code&gt;YOUR_CLIENT_SECRET&lt;/code&gt;, replace them with your own actual values.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Placeholder&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sso.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your Keycloak FQDN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sso.company.net&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;argocd.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your ArgoCD FQDN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;argocd.company.net&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;YOUR_CLIENT_SECRET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The OIDC client secret from Keycloak&lt;/td&gt;
&lt;td&gt;Copy from the Credentials tab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;YOUR_REALM&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your Keycloak realm name&lt;/td&gt;
&lt;td&gt;&lt;code&gt;internal&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Table of Contents&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;#1-prerequisites&quot;&gt;Prerequisites&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-why-native-oidc-instead-of-dex&quot;&gt;Why Native OIDC Instead of Dex&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-create-a-client-for-argocd-in-keycloak&quot;&gt;Create a Client for ArgoCD in Keycloak&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#4-add-a-groups-mapper&quot;&gt;Add a Groups Mapper&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#5-create-groups-in-keycloak&quot;&gt;Create Groups in Keycloak&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#6-store-the-client-secret-in-a-kubernetes-secret&quot;&gt;Store the Client Secret in a Kubernetes Secret&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#7-configure-argocd-cm&quot;&gt;Configure argocd-cm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#8-configure-argocd-rbac-cm&quot;&gt;Configure argocd-rbac-cm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#9-restart--test-login&quot;&gt;Restart &amp;amp; Test Login&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#10-break-glass-account-local-admin&quot;&gt;Break-Glass Account (Local Admin)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#11-offboarding&quot;&gt;Offboarding&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#12-troubleshooting&quot;&gt;Troubleshooting&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;1. Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Keycloak&lt;/strong&gt; is up and running, accessible over HTTPS (e.g., &lt;code&gt;https://sso.example.com&lt;/code&gt;), with a realm already created (see the GitLab integration guide for realm creation steps).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ArgoCD&lt;/strong&gt; is installed on your Kubernetes cluster and accessible over HTTPS (e.g., &lt;code&gt;https://argocd.example.com&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Admin access&lt;/strong&gt; to the Keycloak Admin Console.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;kubectl&lt;/code&gt; access&lt;/strong&gt; with edit permissions on the namespace where ArgoCD is running (e.g., &lt;code&gt;argocd&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;2. Why Native OIDC Instead of Dex&lt;/h2&gt;
&lt;p&gt;ArgoCD supports two approaches for enabling SSO:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dex&lt;/strong&gt; — a built-in authentication proxy that can aggregate multiple identity providers (LDAP, GitHub, SAML, OIDC, etc.) into a single configuration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Native OIDC&lt;/strong&gt; (&lt;code&gt;oidc.config&lt;/code&gt; directly in &lt;code&gt;argocd-cm&lt;/code&gt;) — ArgoCD communicates directly with the Identity Provider without an intermediary.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Since Keycloak already supports OIDC natively and is the only Identity Provider in use, &lt;strong&gt;native OIDC is the simpler choice&lt;/strong&gt; — there&apos;s no need to run an additional Dex component in the cluster, which means fewer moving parts and fewer points of failure. Dex only becomes relevant if you need to aggregate multiple Identity Providers simultaneously.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. Create a Client for ArgoCD in Keycloak&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Log in to &lt;code&gt;https://sso.example.com/admin/&lt;/code&gt; and switch to your realm (e.g., &lt;code&gt;YOUR_REALM&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Navigate to &lt;strong&gt;Clients&lt;/strong&gt; → &lt;strong&gt;Create client&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;General Settings&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;Client type: &lt;code&gt;OpenID Connect&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Client ID: &lt;code&gt;argocd&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Next&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Capability config&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Client authentication&lt;/strong&gt;: &lt;code&gt;On&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Authentication flow&lt;/strong&gt;: check &lt;strong&gt;Standard flow&lt;/strong&gt; only.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Next&lt;/strong&gt;, then &lt;strong&gt;Save&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;On the &lt;strong&gt;Settings&lt;/strong&gt; page:
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Valid redirect URIs&lt;/strong&gt;: &lt;code&gt;https://argocd.example.com/auth/callback&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Valid post logout redirect URIs&lt;/strong&gt;: &lt;code&gt;https://argocd.example.com/*&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Web origins&lt;/strong&gt;: &lt;code&gt;https://argocd.example.com&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Open the &lt;strong&gt;Credentials&lt;/strong&gt; tab and copy the &lt;strong&gt;Client secret&lt;/strong&gt; — you&apos;ll need it in step 6.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;4. Add a Groups Mapper&lt;/h2&gt;
&lt;p&gt;By default, the &lt;code&gt;groups&lt;/code&gt; claim is &lt;strong&gt;not&lt;/strong&gt; automatically included in Keycloak&apos;s OIDC tokens. ArgoCD needs this claim to match users to RBAC roles, so it must be added manually.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;In your realm, navigate to &lt;strong&gt;Client scopes&lt;/strong&gt; → &lt;strong&gt;Create client scope&lt;/strong&gt;.
&lt;ul&gt;
&lt;li&gt;Name: &lt;code&gt;groups&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Type: &lt;code&gt;Default&lt;/code&gt; (not Optional — this ensures the &lt;code&gt;groups&lt;/code&gt; claim is always included in tokens without the client needing to explicitly request it)&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;./client-scopes.jpg&quot; alt=&quot;The &amp;quot;groups&amp;quot; client scope created as a Default scope in Keycloak&apos;s Client Scopes list.&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Enter the newly created &lt;code&gt;groups&lt;/code&gt; client scope → &lt;strong&gt;Mappers&lt;/strong&gt; tab → &lt;strong&gt;Add mapper&lt;/strong&gt; → &lt;strong&gt;By configuration&lt;/strong&gt; → &lt;strong&gt;Group Membership&lt;/strong&gt;.
&lt;ul&gt;
&lt;li&gt;Name: &lt;code&gt;groups&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Token Claim Name: &lt;code&gt;groups&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Full group path&lt;/strong&gt;: &lt;code&gt;Off&lt;/code&gt; (so the output is a plain group name like &lt;code&gt;argocd-admins&lt;/code&gt; rather than &lt;code&gt;/argocd-admins&lt;/code&gt; with the full path)&lt;/li&gt;
&lt;li&gt;Add to ID token: &lt;code&gt;On&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Add to access token: &lt;code&gt;On&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Go back to the &lt;code&gt;argocd&lt;/code&gt; client → &lt;strong&gt;Client scopes&lt;/strong&gt; tab → verify that the &lt;code&gt;groups&lt;/code&gt; scope is already assigned (since it was created as a Default scope, it should automatically appear for all clients).&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;5. Create Groups in Keycloak&lt;/h2&gt;
&lt;p&gt;Create the groups that will be mapped to ArgoCD roles. Here&apos;s an example with three access tiers:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Navigate to &lt;strong&gt;Groups&lt;/strong&gt; → &lt;strong&gt;Create group&lt;/strong&gt;, and create three groups:
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;argocd-admins&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;argocd-developers&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;argocd-viewers&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;./keycloak-groups.jpg&quot; alt=&quot;The three ArgoCD groups (argocd-admins, argocd-developers, argocd-viewers) created in Keycloak&apos;s Groups section.&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Assign users to the appropriate groups via &lt;strong&gt;Users&lt;/strong&gt; → select a user → &lt;strong&gt;Groups&lt;/strong&gt; tab → &lt;strong&gt;Join group&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;6. Store the Client Secret in a Kubernetes Secret&lt;/h2&gt;
&lt;p&gt;The client secret should &lt;strong&gt;never&lt;/strong&gt; be written as plain text in a ConfigMap. Instead, store it in the &lt;code&gt;argocd-secret&lt;/code&gt; Secret that ArgoCD already uses:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl -n argocd patch secret argocd-secret \
  -p &apos;{&quot;stringData&quot;: {&quot;oidc.keycloak.clientSecret&quot;: &quot;YOUR_CLIENT_SECRET&quot;}}&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;7. Configure argocd-cm&lt;/h2&gt;
&lt;p&gt;Edit the &lt;code&gt;argocd-cm&lt;/code&gt; ConfigMap:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl -n argocd edit configmap argocd-cm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add or ensure the &lt;code&gt;data&lt;/code&gt; section contains:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-cm
  namespace: argocd
data:
  url: https://argocd.example.com
  oidc.config: |
    name: Keycloak
    issuer: https://sso.example.com/realms/YOUR_REALM
    clientID: argocd
    clientSecret: $oidc.keycloak.clientSecret
    requestedScopes:
      - openid
      - profile
      - email
      - groups
    requestedIDTokenClaims:
      groups:
        essential: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Key notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;clientSecret: $oidc.keycloak.clientSecret&lt;/code&gt;&lt;/strong&gt; — the &lt;code&gt;$key&lt;/code&gt; syntax tells ArgoCD to read the value from the &lt;code&gt;argocd-secret&lt;/code&gt; Secret (stored in step 6), rather than from plain text in the ConfigMap.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;requestedScopes&lt;/code&gt;&lt;/strong&gt; must include &lt;code&gt;groups&lt;/code&gt;, matching the client scope created in step 4.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If ArgoCD is managed via a Helm chart (rather than manual &lt;code&gt;kubectl edit&lt;/code&gt;), the same configuration can be placed in &lt;code&gt;values.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;configs:
  cm:
    url: https://argocd.example.com
    oidc.config: |
      name: Keycloak
      issuer: https://sso.example.com/realms/YOUR_REALM
      clientID: argocd
      clientSecret: $oidc.keycloak.clientSecret
      requestedScopes: [&quot;openid&quot;, &quot;profile&quot;, &quot;email&quot;, &quot;groups&quot;]
      requestedIDTokenClaims:
        groups:
          essential: true
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;8. Configure argocd-rbac-cm&lt;/h2&gt;
&lt;p&gt;Edit the &lt;code&gt;argocd-rbac-cm&lt;/code&gt; ConfigMap to map Keycloak groups to ArgoCD roles:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl -n argocd edit configmap argocd-rbac-cm
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-rbac-cm
  namespace: argocd
data:
  policy.default: role:readonly
  scopes: &apos;[groups]&apos;
  policy.csv: |
    # &quot;argocd-admins&quot; group gets full admin access
    g, argocd-admins, role:admin

    # &quot;argocd-developers&quot; group can deploy to non-prod environments
    p, role:developer, applications, get, */*, allow
    p, role:developer, applications, sync, dev/*, allow
    p, role:developer, applications, sync, staging/*, allow
    p, role:developer, applications, create, dev/*, allow
    p, role:developer, applications, delete, dev/*, allow
    g, argocd-developers, role:developer

    # &quot;argocd-viewers&quot; group is read-only
    g, argocd-viewers, role:readonly
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Important notes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;scopes: &apos;[groups]&apos;&lt;/code&gt;&lt;/strong&gt; is required — this tells ArgoCD to read the &lt;code&gt;groups&lt;/code&gt; claim from the token when evaluating policies. Without this line, the mapping &lt;code&gt;g, argocd-admins, role:admin&lt;/code&gt; will never match, even if the &lt;code&gt;groups&lt;/code&gt; claim is present in the token.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;policy.default: role:readonly&lt;/code&gt;&lt;/strong&gt; — the default role for users who successfully log in via SSO but don&apos;t belong to any group. You can set this to an empty string (&lt;code&gt;&quot;&quot;&lt;/code&gt;) if you want users without a group assignment to have no access at all.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;9. Restart &amp;amp; Test Login&lt;/h2&gt;
&lt;p&gt;Apply the changes by restarting &lt;code&gt;argocd-server&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl -n argocd rollout restart deployment argocd-server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Wait for the rollout to complete:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl -n argocd rollout status deployment argocd-server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Open &lt;code&gt;https://argocd.example.com&lt;/code&gt; and click the &lt;strong&gt;&quot;LOG IN VIA KEYCLOAK&quot;&lt;/strong&gt; button (the label matches the &lt;code&gt;name&lt;/code&gt; defined in &lt;code&gt;oidc.config&lt;/code&gt;). After successfully authenticating through Keycloak, verify that you have the expected access level based on the group assigned in step 5.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./dashboard-argo.jpg&quot; alt=&quot;The ArgoCD login page showing the &amp;quot;LOG IN VIA KEYCLOAK&amp;quot; button after successful OIDC configuration.&quot; /&gt;&lt;/p&gt;
&lt;p&gt;Optionally, verify the assigned role via the CLI (if the &lt;code&gt;argocd&lt;/code&gt; CLI is installed):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;argocd login argocd.example.com --sso
argocd account get-user-info
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;10. Break-Glass Account (Local Admin)&lt;/h2&gt;
&lt;p&gt;ArgoCD ships with a built-in local &lt;code&gt;admin&lt;/code&gt; account (its initial password is auto-generated and stored in the &lt;code&gt;argocd-initial-admin-secret&lt;/code&gt; Secret). Instead of deleting it, &lt;strong&gt;keep it as an emergency access path&lt;/strong&gt; — similar to the break-glass pattern used in the GitLab integration.&lt;/p&gt;
&lt;p&gt;Retrieve the initial password (if you haven&apos;t already):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath=&quot;{.data.password}&quot; | base64 -d
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Recommendations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Log in once as &lt;code&gt;admin&lt;/code&gt;, change the password to a very strong one, and store it in a password manager.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do not&lt;/strong&gt; delete this &lt;code&gt;admin&lt;/code&gt; account — keep it active but guard the credentials carefully. Only use it if Keycloak goes down and no OIDC users can log in.&lt;/li&gt;
&lt;li&gt;For extra protection, the &lt;code&gt;admin&lt;/code&gt; account can be temporarily disabled via &lt;code&gt;accounts.admin.enabled: &quot;false&quot;&lt;/code&gt; in &lt;code&gt;argocd-cm&lt;/code&gt; after confirming that at least one OIDC group has a working &lt;code&gt;role:admin&lt;/code&gt; assignment — but make sure you have a way to re-enable it (via &lt;code&gt;kubectl edit&lt;/code&gt; directly against the cluster) before doing this.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;11. Offboarding&lt;/h2&gt;
&lt;p&gt;Unlike GitLab (which requires two separate steps due to account linking), ArgoCD &lt;strong&gt;does not store local accounts for OIDC users&lt;/strong&gt; — access is entirely derived from the &lt;code&gt;groups&lt;/code&gt; claim in the token each time a user logs in or re-authenticates. The implications:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Remove the user from their group in Keycloak&lt;/strong&gt; (or disable/delete the user) — ArgoCD access is automatically revoked &lt;strong&gt;the next time the token or session is refreshed&lt;/strong&gt;, with no additional steps needed on the ArgoCD side.&lt;/li&gt;
&lt;li&gt;Active sessions will continue to follow the validity of the already-issued token (&lt;code&gt;requestedIDTokenClaims&lt;/code&gt;, Keycloak&apos;s default token expiry). If you need &lt;strong&gt;immediate revocation&lt;/strong&gt;, revoke the user&apos;s session directly from Keycloak (&lt;strong&gt;Users → select user → Sessions tab → Sign out&lt;/strong&gt;), rather than just removing them from the group.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is one of the key advantages of group-based RBAC over account linking: &lt;strong&gt;there are no separate accounts to manually clean up&lt;/strong&gt; on the ArgoCD side.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;12. Troubleshooting&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely Cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&quot;LOG IN VIA KEYCLOAK&quot; button doesn&apos;t appear&lt;/td&gt;
&lt;td&gt;&lt;code&gt;argocd-cm&lt;/code&gt; hasn&apos;t been applied correctly, or &lt;code&gt;argocd-server&lt;/code&gt; hasn&apos;t been restarted after the ConfigMap change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Login succeeds in Keycloak but ArgoCD shows &quot;permission denied&quot; for all resources&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;groups&lt;/code&gt; claim isn&apos;t reaching ArgoCD — recheck step 4 (the &lt;code&gt;groups&lt;/code&gt; client scope must be &lt;code&gt;Default&lt;/code&gt;, and the mapper must have &lt;code&gt;Add to ID token: On&lt;/code&gt;), and make sure &lt;code&gt;scopes: &apos;[groups]&apos;&lt;/code&gt; is present in &lt;code&gt;argocd-rbac-cm&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Group matches but the role doesn&apos;t behave as expected&lt;/td&gt;
&lt;td&gt;Check the order and syntax of &lt;code&gt;policy.csv&lt;/code&gt; — the &lt;code&gt;g, &amp;lt;group&amp;gt;, role:&amp;lt;role&amp;gt;&lt;/code&gt; line must exactly match the group name in Keycloak (case-sensitive)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client secret error during login&lt;/td&gt;
&lt;td&gt;Ensure the key in the &lt;code&gt;argocd-secret&lt;/code&gt; Secret is named exactly &lt;code&gt;oidc.keycloak.clientSecret&lt;/code&gt;, matching what&apos;s referenced in &lt;code&gt;oidc.config&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invalid_redirect_uri&lt;/code&gt; error from Keycloak&lt;/td&gt;
&lt;td&gt;The redirect URI in the Keycloak client must be exactly &lt;code&gt;https://argocd.example.com/auth/callback&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ConfigMap changes have no effect&lt;/td&gt;
&lt;td&gt;&lt;code&gt;argocd-server&lt;/code&gt; needs a manual restart after &lt;code&gt;kubectl edit configmap&lt;/code&gt; — ArgoCD doesn&apos;t always auto-reload all config sections&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;Summary:&lt;/strong&gt; Create an &lt;code&gt;argocd&lt;/code&gt; client in Keycloak with Standard Flow only → add a &lt;code&gt;groups&lt;/code&gt; client scope with a Group Membership mapper → create groups (&lt;code&gt;argocd-admins&lt;/code&gt;, etc.) and assign users → store the client secret in the Kubernetes &lt;code&gt;argocd-secret&lt;/code&gt; Secret → configure &lt;code&gt;oidc.config&lt;/code&gt; in &lt;code&gt;argocd-cm&lt;/code&gt; → map groups to roles in &lt;code&gt;argocd-rbac-cm&lt;/code&gt; with &lt;code&gt;scopes: &apos;[groups]&apos;&lt;/code&gt; → restart &lt;code&gt;argocd-server&lt;/code&gt; → test login → keep the local &lt;code&gt;admin&lt;/code&gt; account as a break-glass path → offboard users by simply removing them from their Keycloak group.&lt;/p&gt;
</content:encoded></item><item><title>Setting Up ArgoCD with Traefik and HTTPS (Let&apos;s Encrypt) on a Public Domain</title><link>https://im-gatan.com/posts/argocd-traefik-https/</link><guid isPermaLink="true">https://im-gatan.com/posts/argocd-traefik-https/</guid><description>A guide to accessing ArgoCD through a public domain with HTTPS using Traefik as the Ingress Controller and automatic certificates from Let&apos;s Encrypt — no cert-manager required.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In the previous article, we set up &lt;a href=&quot;/posts/argocd-setup-guide/&quot;&gt;ArgoCD with NGINX Ingress Controller and accessed it via a custom endpoint (sub-path)&lt;/a&gt;. That setup works great for experimentation and lab environments — ArgoCD is accessed through the node&apos;s IP with a path like &lt;code&gt;http://&amp;lt;IP&amp;gt;/argocd-lab&lt;/code&gt;, without a domain and without HTTPS.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;But what if you have a domain?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Well, this article is the answer. We&apos;ll configure ArgoCD to be accessible through a &lt;strong&gt;public domain&lt;/strong&gt; (e.g., &lt;code&gt;argocd.example.com&lt;/code&gt;) with &lt;strong&gt;HTTPS&lt;/strong&gt; using certificates automatically generated by &lt;strong&gt;Let&apos;s Encrypt&lt;/strong&gt; — all handled by &lt;strong&gt;Traefik&lt;/strong&gt; as the Ingress Controller, without needing to install cert-manager separately.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Intended for kubeadm bare-metal/on-prem clusters&lt;/strong&gt; with a public domain whose DNS is already pointed to the node&apos;s IP.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;⚠️ Important Note: Not Production-Ready Yet&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;[!WARNING]
The configuration in this article is &lt;strong&gt;not suitable for production&lt;/strong&gt; because several aspects are still insecure or suboptimal:&lt;/p&gt;
&lt;/blockquote&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Issue&lt;/th&gt;
&lt;th&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Traefik runs as root&lt;/strong&gt; (&lt;code&gt;runAsUser: 0&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Container runs with full privileges&lt;/td&gt;
&lt;td&gt;If the container is exploited, the attacker gains root access on the node&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;hostNetwork: true&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Traefik pod shares the host&apos;s network namespace&lt;/td&gt;
&lt;td&gt;Port conflicts, no network isolation, an attacker who enters the pod gets direct host network access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;allowPrivilegeEscalation: true&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Container can escalate privileges&lt;/td&gt;
&lt;td&gt;Significantly expands the attack surface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;readOnlyRootFilesystem: false&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Container can write to any filesystem location&lt;/td&gt;
&lt;td&gt;Attacker can modify binaries or configs inside the container&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PersistentVolume &lt;code&gt;hostPath&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Certificate data is stored directly on the node&apos;s filesystem&lt;/td&gt;
&lt;td&gt;No redundancy, data is lost if the node dies, not portable across nodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Single replica Traefik&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Only one ingress controller instance&lt;/td&gt;
&lt;td&gt;Single point of failure — if the pod dies, all traffic to the cluster stops&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;💡 Recommendations for Production&lt;/h3&gt;
&lt;p&gt;Here are some recommendations if you plan to bring this setup to production:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Use Longhorn for Persistent Volumes&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Instead of using &lt;code&gt;hostPath&lt;/code&gt; which stores data directly on the node, use &lt;strong&gt;&lt;a href=&quot;https://longhorn.io/&quot;&gt;Longhorn&lt;/a&gt;&lt;/strong&gt; — a distributed block storage solution for Kubernetes. Longhorn provides:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Data replication&lt;/strong&gt; across nodes (data survives node failures)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automatic snapshots &amp;amp; backups&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Volume portability&lt;/strong&gt; — pods can move between nodes without losing data&lt;/li&gt;
&lt;li&gt;Built-in monitoring dashboard&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;2. Use MetalLB for Network Management&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Instead of using &lt;code&gt;hostNetwork: true&lt;/code&gt; (which bypasses network isolation), use &lt;strong&gt;&lt;a href=&quot;https://metallb.universe.tf/&quot;&gt;MetalLB&lt;/a&gt;&lt;/strong&gt; as a bare-metal LoadBalancer:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Provides &lt;strong&gt;proper external IPs&lt;/strong&gt; for &lt;code&gt;type: LoadBalancer&lt;/code&gt; Services&lt;/li&gt;
&lt;li&gt;Eliminates the need for &lt;code&gt;hostNetwork&lt;/code&gt; — Traefik can run with normal network isolation&lt;/li&gt;
&lt;li&gt;Supports &lt;strong&gt;L2 (ARP)&lt;/strong&gt; and &lt;strong&gt;BGP&lt;/strong&gt; modes&lt;/li&gt;
&lt;li&gt;Enables &lt;strong&gt;multiple Traefik replicas&lt;/strong&gt; with the same IP (high availability)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;3. Harden the Security Context&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For production, improve Traefik&apos;s security context:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;runAsNonRoot: true&lt;/code&gt; with &lt;code&gt;NET_BIND_SERVICE&lt;/code&gt; capability&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;readOnlyRootFilesystem: true&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;allowPrivilegeEscalation: false&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Drop all capabilities except the ones actually needed&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;A production-grade setup with Longhorn + MetalLB + hardened Traefik will be covered in a separate article. For now, let&apos;s focus on getting a functional setup first.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A running Kubernetes cluster (kubeadm)&lt;/li&gt;
&lt;li&gt;A public domain pointed to the node&apos;s IP (&lt;code&gt;A record: argocd.example.com → &amp;lt;NODE-IP&amp;gt;&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Ports &lt;strong&gt;80&lt;/strong&gt; and &lt;strong&gt;443&lt;/strong&gt; on the node are open from the internet&lt;/li&gt;
&lt;li&gt;&lt;code&gt;kubectl&lt;/code&gt; and &lt;code&gt;helm&lt;/code&gt; are configured&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If Helm is not yet installed:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;version.BuildInfo{Version:&quot;v4.2.3&quot;, GitCommit:&quot;43e8b7feece8beb0fcba47059ec9b522fd929a64&quot;, GitTreeState:&quot;clean&quot;, GoVersion:&quot;go1.26.5&quot;, KubeClientVersion:&quot;v1.36&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;1. Deploy ArgoCD&lt;/h2&gt;
&lt;p&gt;Download the official ArgoCD manifest.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wget https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Edit the manifest to disable ArgoCD&apos;s built-in TLS, since HTTPS will be handled entirely
by Traefik in front of it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nano install.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Find the &lt;code&gt;Deployment&lt;/code&gt; named &lt;code&gt;argocd-server&lt;/code&gt; and change the &lt;code&gt;args&lt;/code&gt; section to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;spec:
  containers:
  - args:
    - /usr/local/bin/argocd-server
    - --staticassets
    - /shared/app
    - --insecure
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why is &lt;code&gt;--insecure&lt;/code&gt; safe here?&lt;/strong&gt;
This flag only makes ArgoCD serve HTTP from the pod side. Traefik sits in front and
handles TLS termination — so external communication remains HTTPS-encrypted.
Internal communication from Traefik to the ArgoCD pod goes over HTTP within the cluster,
which is already isolated.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Create the namespace and deploy.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl create namespace argocd
kubectl apply --server-side -f install.yaml -n argocd
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why use &lt;code&gt;--server-side&lt;/code&gt;?&lt;/strong&gt;
Recent ArgoCD versions have very large CRDs, especially
&lt;code&gt;applicationsets.argoproj.io&lt;/code&gt;. By default, &lt;code&gt;kubectl apply&lt;/code&gt; stores the entire manifest
content as a &lt;code&gt;kubectl.kubernetes.io/last-applied-configuration&lt;/code&gt; annotation on the CRD
object itself. Kubernetes limits annotation size to a maximum of &lt;strong&gt;256KB (262144 bytes)&lt;/strong&gt; — and
ArgoCD&apos;s CRDs exceed this limit, causing regular &lt;code&gt;kubectl apply&lt;/code&gt; to fail with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;The CustomResourceDefinition &quot;applicationsets.argoproj.io&quot; is invalid:
metadata.annotations: Too long: may not be more than 262144 bytes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With &lt;code&gt;--server-side&lt;/code&gt;, the apply process is handled on the API server side (not the client),
so the &lt;code&gt;last-applied-configuration&lt;/code&gt; annotation is not stored and the 256KB limit doesn&apos;t apply.&lt;/p&gt;
&lt;p&gt;If a CRD was previously applied the regular way and a conflict occurs, add
the &lt;code&gt;--force-conflicts&lt;/code&gt; flag:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply --server-side --force-conflicts -f install.yaml -n argocd
&lt;/code&gt;&lt;/pre&gt;
&lt;/blockquote&gt;
&lt;p&gt;Wait until all pods are in &lt;code&gt;Running&lt;/code&gt; status.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get pods -n argocd
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. Install Traefik via Helm&lt;/h2&gt;
&lt;p&gt;Add the official Traefik Helm repository.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;helm repo add traefik https://traefik.github.io/charts
helm repo update
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the &quot;traefik&quot; chart repository
Update Complete. ⎈Happy Helming!⎈
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;First, create a directory on the host and a PersistentVolume to persist
Let&apos;s Encrypt certificates (acme.json).&lt;/p&gt;
&lt;p&gt;Create the directory on the host node:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p /data/traefik
chmod 700 /data/traefik
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a &lt;code&gt;pv.yaml&lt;/code&gt; file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nano pv.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: v1
kind: PersistentVolume
metadata:
  name: traefik-pv
spec:
  capacity:
    storage: 1Gi
  accessModes:
    - ReadWriteOnce
  hostPath:
    path: /data/traefik
  persistentVolumeReclaimPolicy: Retain
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f pv.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Create a &lt;code&gt;traefik-values.yaml&lt;/code&gt; file to configure Traefik for bare-metal
and Let&apos;s Encrypt.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nano traefik-values.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# Use Deployment (not DaemonSet) because Traefik v3 does not support
# ACME/Let&apos;s Encrypt when running as a DaemonSet — each node would
# try to request certificates independently and conflict on acme.json
deployment:
  kind: Deployment
  replicas: 1

hostNetwork: true

service:
  type: ClusterIP

ports:
  web:
    port: 80
    hostPort: 80
    expose:
      default: true
  websecure:
    port: 443
    hostPort: 443
    expose:
      default: true

additionalArguments:
  - &quot;--certificatesresolvers.letsencrypt.acme.email=email@example.com&quot;
  - &quot;--certificatesresolvers.letsencrypt.acme.storage=/data/acme.json&quot;
  - &quot;--certificatesresolvers.letsencrypt.acme.tlsChallenge=true&quot;

persistence:
  enabled: true
  storageClass: &quot;&quot;
  size: 128Mi

ingressClass:
  enabled: true
  isDefaultClass: false
  name: traefik

ingressRoute:
  dashboard:
    enabled: false

# ==============================================================================
# ROOT CONFIGURATION
# Forces Traefik to run as User 0 (Root) so it can freely use
# host ports 80/443 and manage the acme.json file
# ==============================================================================
podSecurityContext:
  runAsUser: 0
  runAsGroup: 0
  runAsNonRoot: false
  fsGroup: 0

securityContext:
  readOnlyRootFilesystem: false
  runAsNonRoot: false
  runAsUser: 0
  runAsGroup: 0
  allowPrivilegeEscalation: true
  capabilities:
    drop: []
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;Replace &lt;code&gt;email@example.com&lt;/code&gt; with your active email address for Let&apos;s Encrypt notifications.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Breaking changes in Traefik v3 (Helm chart &amp;gt;= 30.x):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;certResolvers&lt;/code&gt; at the values level is no longer recognized — ACME configuration is now passed
directly via &lt;code&gt;additionalArguments&lt;/code&gt; to the Traefik binary&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tls&lt;/code&gt; inside &lt;code&gt;ports.websecure&lt;/code&gt; is no longer allowed in the v3 schema&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tlsStore.default.defaultCertResolver&lt;/code&gt; has been &lt;strong&gt;removed from the CRD&lt;/strong&gt; — do not use it&lt;/li&gt;
&lt;li&gt;Cert resolver is specified &lt;strong&gt;per-Ingress&lt;/strong&gt; via annotation:
&lt;code&gt;traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Explanation of other configuration choices:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Deployment&lt;/code&gt; + &lt;code&gt;hostNetwork: true&lt;/code&gt; — Traefik binds directly to host ports,
the simplest solution for bare-metal without a LoadBalancer. Using Deployment
(not DaemonSet) ensures only one replica manages ACME certificates&lt;/li&gt;
&lt;li&gt;&lt;code&gt;tlsChallenge=true&lt;/code&gt; — Traefik verifies the domain with Let&apos;s Encrypt via the TLS-ALPN-01 challenge
directly on port 443, no need for port 80 for the challenge (but port 80 is still open
for HTTP → HTTPS redirect)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;persistence.enabled: true&lt;/code&gt; — certificates are stored on a PersistentVolume so they don&apos;t need
to be re-requested from Let&apos;s Encrypt every time the pod restarts (Let&apos;s Encrypt has strict rate limits)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Install Traefik to the &lt;code&gt;traefik&lt;/code&gt; namespace.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;helm install traefik traefik/traefik \
  --namespace traefik \
  --create-namespace \
  --values traefik-values.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME: traefik
LAST DEPLOYED: Thu Jul 16 21:08:26 2026
NAMESPACE: traefik
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
TEST SUITE: None
NOTES:
traefik with docker.io/traefik:v3.7.6 has been deployed successfully on traefik namespace!
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify that the Traefik pod is &lt;code&gt;Running&lt;/code&gt; and the IngressClass is registered.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get pods -n traefik
kubectl get ingressclass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME      CONTROLLER                      PARAMETERS   AGE
traefik   traefik.io/ingress-controller   &amp;lt;none&amp;gt;       30s
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;3. Create an Ingress for ArgoCD&lt;/h2&gt;
&lt;p&gt;Create an &lt;code&gt;ingress.yaml&lt;/code&gt; file. Traefik will automatically request a certificate from Let&apos;s Encrypt
based on the &lt;code&gt;traefik.ingress.kubernetes.io/router.tls.certresolver&lt;/code&gt; annotation.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nano ingress.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: argocd-server-ingress
  namespace: argocd
  annotations:
    # Use the certresolver configured in Traefik values
    traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
    # Redirect all HTTP to HTTPS
    traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
    traefik.ingress.kubernetes.io/router.middlewares: argocd-redirect-https@kubernetescrd
spec:
  ingressClassName: traefik
  tls:
  - hosts:
    - argocd.example.com
  rules:
  - host: argocd.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: argocd-server
            port:
              number: 80
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also create a Middleware for HTTP to HTTPS redirect.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nano middleware.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: redirect-https
  namespace: argocd
spec:
  redirectScheme:
    scheme: https
    permanent: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apply both.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f middleware.yaml
kubectl apply -f ingress.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify the ingress is registered.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get ingress -n argocd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME                    CLASS     HOSTS                 ADDRESS          PORTS     AGE
argocd-server-ingress   traefik   argocd.example.com    192.168.205.71   80, 443   1m
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Monitor the certificate issuance process from the Traefik logs. This usually takes 1-2 minutes.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl logs -n traefik -l app.kubernetes.io/name=traefik --follow
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The certificate has been successfully issued if you see logs like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;2026-07-16T14:41:28Z INF Creating in-cluster Provider client providerName=kubernetescrd
2026-07-16T14:41:28Z INF Starting provider *acme.Provider
2026-07-16T14:41:28Z INF Testing certificate renew... acmeCA=https://acme-v02.api.letsencrypt.org/directory providerName=letsencrypt.acme
2026-07-16T14:45:02Z INF Register... providerName=letsencrypt.acme
2026-07-16T14:45:02Z INF Registering the account. email=email@example.com lib=lego
2026-07-16T14:45:02Z INF Obtaining bundled SAN certificate. domains=argocd.example.com lib=lego
2026-07-16T14:45:03Z INF Use solver. domain=argocd.example.com lib=lego type=tls-alpn-01
2026-07-16T14:45:03Z INF tlsalpn01: Trying to solve TLS-ALPN-01. domain=argocd.example.com lib=lego
2026-07-16T14:45:08Z INF The server validated our request. domain=argocd.example.com lib=lego
2026-07-16T14:45:08Z INF Validations succeeded; requesting certificates. domains=argocd.example.com lib=lego
2026-07-16T14:45:13Z INF Server responded with a certificate. domains=argocd.example.com lib=lego
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;4. Install the ArgoCD CLI&lt;/h2&gt;
&lt;p&gt;Download and install the ArgoCD CLI binary.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -SL -o argocd-linux-amd64 https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
sudo install -m 555 argocd-linux-amd64 /usr/local/bin/argocd
rm argocd-linux-amd64
argocd version
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;5. Login and Password Setup&lt;/h2&gt;
&lt;h3&gt;5.1 Retrieve the Initial Admin Password&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;argocd admin initial-password -n argocd
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.2 Login via CLI&lt;/h3&gt;
&lt;p&gt;Since we&apos;re already using HTTPS with a valid certificate from Let&apos;s Encrypt, login is done
directly without the &lt;code&gt;--insecure&lt;/code&gt; or &lt;code&gt;--plaintext&lt;/code&gt; flags.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;argocd login argocd.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Username: admin
Password: &amp;lt;password-from-the-previous-step&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;If the certificate hasn&apos;t finished being issued yet, you&apos;ll get a TLS error. Wait a few minutes
and try again.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;5.3 Change the Admin Password&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;argocd account update-password
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;6. Access ArgoCD via Browser&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;https://argocd.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The browser shows the HTTPS padlock because the certificate from Let&apos;s Encrypt is valid.
Accessing via &lt;code&gt;http://&lt;/code&gt; will automatically redirect to &lt;code&gt;https://&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./argocd-dashboard.png&quot; alt=&quot;ArgoCD Dashboard — login page with HTTPS active&quot; /&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7. Tuning the Refresh Time Interval&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;nano config.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-cm
  namespace: argocd
  labels:
    app.kubernetes.io/name: argocd-cm
    app.kubernetes.io/part-of: argocd
data:
  timeout.reconciliation: 20s
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f config.yaml
kubectl describe configmap -n argocd argocd-cm
kubectl -n argocd rollout restart deploy argocd-repo-server
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Troubleshooting&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Certificate is not being issued&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Make sure port 443 is open from the internet. Check the Traefik logs for error details:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl logs -n traefik -l app.kubernetes.io/name=traefik | grep -i &quot;acme\|cert\|error&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;argocd login&lt;/code&gt; fails with a TLS error&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The certificate hasn&apos;t finished being issued yet. Wait until the &lt;code&gt;Obtained certificate&lt;/code&gt; log appears in Traefik,
then try logging in again.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Traefik pod won&apos;t start&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Make sure no other process is already using port 80 or 443 on the node. Check with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ss -tlnp | grep -E &apos;:80|:443&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Certificate expired and not auto-renewing&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Make sure the PersistentVolume for Traefik still exists and is accessible:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get pvc -n traefik
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Comparison: Traefik vs NGINX Ingress + cert-manager&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Aspect&lt;/th&gt;
&lt;th&gt;Traefik (this doc)&lt;/th&gt;
&lt;th&gt;NGINX + cert-manager&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Components&lt;/td&gt;
&lt;td&gt;Traefik only&lt;/td&gt;
&lt;td&gt;NGINX Ingress + cert-manager + ClusterIssuer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Let&apos;s Encrypt&lt;/td&gt;
&lt;td&gt;Built-in, configured in values&lt;/td&gt;
&lt;td&gt;Separate via cert-manager CRDs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configuration&lt;/td&gt;
&lt;td&gt;&lt;code&gt;traefik-values.yaml&lt;/code&gt; + annotations&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ingress.yaml&lt;/code&gt; + &lt;code&gt;clusterissuer.yaml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monitoring dashboard&lt;/td&gt;
&lt;td&gt;Available (optional)&lt;/td&gt;
&lt;td&gt;No built-in dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra CRDs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IngressRoute&lt;/code&gt;, &lt;code&gt;Middleware&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Certificate&lt;/code&gt;, &lt;code&gt;ClusterIssuer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complexity&lt;/td&gt;
&lt;td&gt;Simpler (fewer components)&lt;/td&gt;
&lt;td&gt;More components but more flexible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best suited for&lt;/td&gt;
&lt;td&gt;Lab, staging, simple production&lt;/td&gt;
&lt;td&gt;Production with many different certificates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://argo-cd.readthedocs.io/en/stable/&quot;&gt;ArgoCD Official Documentation — argoproj.github.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://doc.traefik.io/traefik/&quot;&gt;Traefik Official Documentation — doc.traefik.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://letsencrypt.org/&quot;&gt;Let&apos;s Encrypt — letsencrypt.org&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://longhorn.io/&quot;&gt;Longhorn — Cloud Native Distributed Storage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://metallb.universe.tf/&quot;&gt;MetalLB — Bare Metal Load Balancer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;/posts/argocd-setup-guide/&quot;&gt;Previous article: Setting Up ArgoCD with NGINX Ingress&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This article is a continuation of the bare-metal Kubernetes installation series. Adjust domains, IPs, and version numbers to match your own environment.&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>Deploying Keycloak 26 in Production on AlmaLinux 9 with PostgreSQL, Nginx &amp; Let&apos;s Encrypt</title><link>https://im-gatan.com/posts/deploy-keycloak-almalinux9/</link><guid isPermaLink="true">https://im-gatan.com/posts/deploy-keycloak-almalinux9/</guid><description>A complete guide to deploying Keycloak 26.7.0 on AlmaLinux 9 using OpenJDK 25, Percona Distribution for PostgreSQL 17, systemd, and Nginx as a TLS-terminating reverse proxy with Let&apos;s Encrypt.</description><pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This guide covers a full production deployment of &lt;strong&gt;Keycloak 26.7.0&lt;/strong&gt; on &lt;strong&gt;AlmaLinux 9&lt;/strong&gt;. We&apos;ll use &lt;strong&gt;OpenJDK 25&lt;/strong&gt; as the runtime, &lt;strong&gt;Percona Distribution for PostgreSQL 17&lt;/strong&gt; as the database, run Keycloak as a &lt;strong&gt;systemd service&lt;/strong&gt;, and secure it behind an &lt;strong&gt;Nginx reverse proxy&lt;/strong&gt; with a free &lt;strong&gt;Let&apos;s Encrypt&lt;/strong&gt; TLS certificate.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Placeholders used throughout this guide:&lt;/strong&gt;
Wherever you see values like &lt;code&gt;your-domain.example.com&lt;/code&gt;, &lt;code&gt;YOUR_STRONG_DB_PASSWORD&lt;/code&gt;, or &lt;code&gt;YOUR_ADMIN_PASSWORD&lt;/code&gt;, replace them with your own. A summary of all placeholders is provided below.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Placeholder&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;your-domain.example.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Your Keycloak FQDN (must have a DNS A record pointing to your server&apos;s public IP)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sso.example.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;YOUR_STRONG_DB_PASSWORD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Password for the PostgreSQL &lt;code&gt;kc_user&lt;/code&gt; database user&lt;/td&gt;
&lt;td&gt;Use a password manager to generate one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;YOUR_ADMIN_USERNAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keycloak admin console username&lt;/td&gt;
&lt;td&gt;&lt;code&gt;admin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;YOUR_ADMIN_PASSWORD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keycloak admin console password&lt;/td&gt;
&lt;td&gt;Use a strong, unique password&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AlmaLinux 9&lt;/strong&gt; (fresh install recommended), minimum 2 vCPU / 4 GB RAM for small-to-medium workloads.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Root access&lt;/strong&gt; or a user with &lt;code&gt;sudo&lt;/code&gt; privileges.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;domain name&lt;/strong&gt; (e.g., &lt;code&gt;your-domain.example.com&lt;/code&gt;) with an A record pointing to your server&apos;s public IP.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ports 80 and 443&lt;/strong&gt; open to the internet for Let&apos;s Encrypt validation and HTTPS traffic.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Component Versions&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OS&lt;/td&gt;
&lt;td&gt;AlmaLinux 9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;OpenJDK 25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;PostgreSQL 17 (Percona Distribution)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keycloak&lt;/td&gt;
&lt;td&gt;26.7.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web Server&lt;/td&gt;
&lt;td&gt;Nginx (reverse proxy)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSL&lt;/td&gt;
&lt;td&gt;Let&apos;s Encrypt (Certbot)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;1. System Preparation&lt;/h2&gt;
&lt;p&gt;Update existing packages and install basic utilities:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo dnf update -y
sudo dnf install -y wget curl unzip tar policycoreutils-python-utils
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Optionally, set the hostname for log and certificate consistency:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Replace with your actual domain
sudo hostnamectl set-hostname your-domain.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;2. Install OpenJDK 25&lt;/h2&gt;
&lt;p&gt;The default AlmaLinux 9 AppStream repository only provides up to OpenJDK 21. For OpenJDK 25, we&apos;ll download the official build directly.&lt;/p&gt;
&lt;h3&gt;2.1 Download &amp;amp; Extract&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd /opt
sudo wget https://download.java.net/java/GA/jdk25.0.2/b1e0dfa218384cb9959bdcb897162d4e/10/GPL/openjdk-25.0.2_linux-x64_bin.tar.gz

sudo tar -xzf openjdk-25.0.2_linux-x64_bin.tar.gz
sudo mv jdk-25.0.2 /opt/jdk-25
sudo rm openjdk-25.0.2_linux-x64_bin.tar.gz
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; The latest build download links are always available at &lt;a href=&quot;https://jdk.java.net/25/&quot;&gt;https://jdk.java.net/25/&lt;/a&gt; — check for the latest build number before installing, as the URL changes with each update release.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;2.2 Register via &lt;code&gt;alternatives&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo alternatives --install /usr/bin/java java /opt/jdk-25/bin/java 2500
sudo alternatives --install /usr/bin/javac javac /opt/jdk-25/bin/javac 2500

sudo alternatives --set java /opt/jdk-25/bin/java
sudo alternatives --set javac /opt/jdk-25/bin/javac
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.3 Set &lt;code&gt;JAVA_HOME&lt;/code&gt; System-Wide&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo tee /etc/profile.d/java25.sh &amp;gt; /dev/null &amp;lt;&amp;lt;&apos;EOF&apos;
export JAVA_HOME=/opt/jdk-25
export PATH=$JAVA_HOME/bin:$PATH
EOF

source /etc/profile.d/java25.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.4 Verify&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;java --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;openjdk 25.0.2 2026-01-20
OpenJDK Runtime Environment (build 25.0.2+10-69)
OpenJDK 64-Bit Server VM (build 25.0.2+10-69, mixed mode, sharing)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;3. Install &amp;amp; Configure PostgreSQL 17 (Percona Distribution)&lt;/h2&gt;
&lt;p&gt;As an alternative to the community PostgreSQL (PGDG) packages, this guide uses &lt;strong&gt;Percona Distribution for PostgreSQL&lt;/strong&gt; — a 100% upstream-compatible PostgreSQL build from Percona, bundled with operational extensions (&lt;code&gt;pgBackRest&lt;/code&gt;, &lt;code&gt;pg_stat_monitor&lt;/code&gt;, &lt;code&gt;pgAudit&lt;/code&gt;, &lt;code&gt;Patroni&lt;/code&gt;, etc.) useful for long-term production needs. Official reference: &lt;a href=&quot;https://docs.percona.com/postgresql/17/yum.html&quot;&gt;docs.percona.com/postgresql/17/yum.html&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The service structure, data paths, and binary names follow upstream PostgreSQL conventions (&lt;code&gt;postgresql-17.service&lt;/code&gt;, &lt;code&gt;/usr/pgsql-17/&lt;/code&gt;, &lt;code&gt;/var/lib/pgsql/17/data/&lt;/code&gt;), so all post-installation steps (initialization, &lt;code&gt;pg_hba.conf&lt;/code&gt;, systemd) are identical.&lt;/p&gt;
&lt;h3&gt;3.1 Install Dependencies &amp;amp; Percona Repository&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo dnf -y install curl

# Install the Percona repository management tool
sudo dnf install -y https://repo.percona.com/yum/percona-release-latest.noarch.rpm

# Enable the Percona Distribution for PostgreSQL 17 repository
sudo percona-release setup ppg17

# Disable the default AppStream postgresql module to avoid conflicts
sudo dnf -qy module disable postgresql
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.2 Install Percona PostgreSQL Server&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo dnf install -y percona-postgresql17-server percona-postgresql17-contrib
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.3 Initialize &amp;amp; Start the Service&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo /usr/pgsql-17/bin/postgresql-17-setup initdb

sudo systemctl enable --now postgresql-17
sudo systemctl status postgresql-17
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify that the installed build is from Percona:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;psql --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;psql (PostgreSQL) 17.10 - Percona Server for PostgreSQL 17.10.2
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.4 Create Database &amp;amp; User for Keycloak&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# ⚠️ Replace YOUR_STRONG_DB_PASSWORD with a secure password
sudo -u postgres psql &amp;lt;&amp;lt;&apos;EOF&apos;
CREATE DATABASE keycloak;
CREATE USER kc_user WITH ENCRYPTED PASSWORD &apos;YOUR_STRONG_DB_PASSWORD&apos;;
GRANT ALL PRIVILEGES ON DATABASE keycloak TO kc_user;
ALTER DATABASE keycloak OWNER TO kc_user;
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.5 Enable Password Authentication (scram-sha-256)&lt;/h3&gt;
&lt;p&gt;By default, PostgreSQL on RHEL-family systems uses &lt;code&gt;peer&lt;/code&gt; authentication for local connections. Since Keycloak connects via TCP (&lt;code&gt;127.0.0.1&lt;/code&gt;), we need to add a password authentication entry to &lt;code&gt;pg_hba.conf&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo tee -a /var/lib/pgsql/17/data/pg_hba.conf &amp;gt; /dev/null &amp;lt;&amp;lt;&apos;EOF&apos;
host    keycloak    kc_user    127.0.0.1/32    scram-sha-256
EOF

sudo systemctl restart postgresql-17
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;4. Install Keycloak 26.7.0&lt;/h2&gt;
&lt;h3&gt;4.1 Create a Dedicated System User&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo groupadd keycloak
sudo useradd -r -g keycloak -d /opt/keycloak -s /sbin/nologin keycloak
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.2 Download &amp;amp; Extract Keycloak&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd /tmp
wget https://github.com/keycloak/keycloak/releases/download/26.7.0/keycloak-26.7.0.zip

sudo unzip keycloak-26.7.0.zip -d /opt/
sudo mv /opt/keycloak-26.7.0 /opt/keycloak
sudo chown -R keycloak:keycloak /opt/keycloak
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;5. Configure &lt;code&gt;keycloak.conf&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;Edit the Keycloak configuration file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo nano /opt/keycloak/conf/keycloak.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Paste the following configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ---------- Database ----------
db=postgres
db-username=kc_user
db-password=YOUR_STRONG_DB_PASSWORD          # ⚠️ Use the same password from step 3.4
db-url=jdbc:postgresql://127.0.0.1:5432/keycloak

# ---------- Hostname ----------
hostname=your-domain.example.com             # ⚠️ Replace with your actual domain
hostname-strict-backchannel=true

# ---------- Proxy (behind Nginx) ----------
proxy-headers=xforwarded
http-enabled=true
http-host=127.0.0.1
http-port=8080

# ---------- Production Features ----------
health-enabled=true
metrics-enabled=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since TLS termination is handled by Nginx in front, Keycloak only needs to listen for HTTP connections locally (&lt;code&gt;127.0.0.1:8080&lt;/code&gt;) and is never directly exposed to the internet.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. Build &amp;amp; Bootstrap Admin&lt;/h2&gt;
&lt;h3&gt;6.1 Build Optimized Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd /opt/keycloak
sudo -u keycloak bin/kc.sh build
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Updating the configuration and installing your custom providers, if any. Please wait.
...
Server configuration updated and persisted. Run the following command to review the configuration:

        kc.sh show-config
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.2 Create the Initial Admin Account&lt;/h3&gt;
&lt;p&gt;Since Keycloak 26.x, the &lt;code&gt;--password&lt;/code&gt; flag on the &lt;code&gt;bootstrap-admin&lt;/code&gt; command has been removed for security reasons (to avoid passwords being recorded in shell history or visible via &lt;code&gt;ps aux&lt;/code&gt;). Use one of the following methods:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Method 1 — Interactive&lt;/strong&gt; (recommended for a one-time manual setup):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ⚠️ Replace YOUR_ADMIN_USERNAME with your desired admin username
sudo -u keycloak bin/kc.sh bootstrap-admin user --username YOUR_ADMIN_USERNAME
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You will be prompted to type the password directly in the terminal. Choose a strong, unique password and store it securely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Method 2 — Via Environment Variable&lt;/strong&gt; (for automation/scripted setups):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ⚠️ Replace the values below with your desired admin credentials
export KC_BOOTSTRAP_ADMIN_PASSWORD=&apos;YOUR_ADMIN_PASSWORD&apos;

sudo -u keycloak -E bin/kc.sh bootstrap-admin user \
  --username YOUR_ADMIN_USERNAME \
  --password:env KC_BOOTSTRAP_ADMIN_PASSWORD
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-E&lt;/code&gt; flag on &lt;code&gt;sudo&lt;/code&gt; is required to pass the &lt;code&gt;KC_BOOTSTRAP_ADMIN_PASSWORD&lt;/code&gt; environment variable to the process running as the &lt;code&gt;keycloak&lt;/code&gt; user. After the account is created, remove the variable from your shell:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;unset KC_BOOTSTRAP_ADMIN_PASSWORD
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;7. Systemd Service&lt;/h2&gt;
&lt;p&gt;Create the unit file at &lt;code&gt;/etc/systemd/system/keycloak.service&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Unit]
Description=Keycloak Identity and Access Management Server
After=network.target postgresql-17.service
Wants=postgresql-17.service

[Service]
Type=simple
User=keycloak
Group=keycloak
WorkingDirectory=/opt/keycloak
Environment=&quot;JAVA_HOME=/opt/jdk-25&quot;
Environment=&quot;JAVA_OPTS=-Xms512m -Xmx2048m&quot;
ExecStart=/opt/keycloak/bin/kc.sh start --optimized
ExecStop=/opt/keycloak/bin/kc.sh stop
Restart=on-failure
RestartSec=10
TimeoutStartSec=300

# Basic hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/keycloak

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;PrivateTmp=true&lt;/code&gt;?&lt;/strong&gt; This directive is essential when used alongside &lt;code&gt;ProtectSystem=strict&lt;/code&gt;. Without it, Keycloak (via Quarkus/Vert.x) will fail to start with a &lt;code&gt;Read-only file system&lt;/code&gt; error when attempting to write temporary cache files to &lt;code&gt;/tmp&lt;/code&gt;, because &lt;code&gt;ProtectSystem=strict&lt;/code&gt; locks the entire filesystem except paths listed in &lt;code&gt;ReadWritePaths&lt;/code&gt;. &lt;code&gt;PrivateTmp=true&lt;/code&gt; gives the service its own private, writable &lt;code&gt;/tmp&lt;/code&gt; without weakening the overall hardening posture.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Enable and start the service:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl daemon-reload
sudo systemctl enable --now keycloak
sudo systemctl status keycloak
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check logs if anything goes wrong:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo journalctl -u keycloak -f
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;8. Reverse Proxy with Nginx + SSL (Let&apos;s Encrypt)&lt;/h2&gt;
&lt;h3&gt;8.1 Install Nginx &amp;amp; Certbot&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo dnf install -y epel-release
sudo dnf install -y nginx certbot python3-certbot-nginx

sudo systemctl enable --now nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;8.2 Create the Virtual Host Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# ⚠️ Replace your-domain.example.com with your actual domain (in both the filename and content)
sudo tee /etc/nginx/conf.d/your-domain.example.com.conf &amp;gt; /dev/null &amp;lt;&amp;lt;&apos;EOF&apos;
server {
    listen 80;
    server_name your-domain.example.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_buffer_size 128k;
        proxy_buffers 4 256k;
        proxy_busy_buffers_size 256k;
    }
}
EOF

sudo nginx -t
sudo systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;8.3 Issue the TLS Certificate&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# ⚠️ Replace your-domain.example.com with your actual domain
sudo certbot --nginx -d your-domain.example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Certbot will automatically add the &lt;code&gt;listen 443 ssl&lt;/code&gt; block and redirect HTTP traffic to HTTPS. Certificate renewal runs automatically via the systemd timer (&lt;code&gt;certbot-renew.timer&lt;/code&gt;), which you can verify with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;systemctl list-timers | grep certbot
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;9. Firewall &amp;amp; SELinux&lt;/h2&gt;
&lt;h3&gt;9.1 Firewalld&lt;/h3&gt;
&lt;p&gt;AlmaLinux 9 runs &lt;code&gt;firewalld&lt;/code&gt; by default, so all port management in this guide is done through &lt;code&gt;firewall-cmd&lt;/code&gt; rather than disabling the firewall.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Check the active zone and firewalld status:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl status firewalld
sudo firewall-cmd --get-default-zone
sudo firewall-cmd --list-all
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Open HTTP and HTTPS services&lt;/strong&gt; (required for Let&apos;s Encrypt validation and traffic to Nginx):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Verify allowed services:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo firewall-cmd --list-services
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Ports intentionally NOT opened in firewalld:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;8080&lt;/strong&gt; (Keycloak internal) — Only bound to &lt;code&gt;127.0.0.1&lt;/code&gt; and accessed locally by Nginx. It must not be exposed to the public internet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;5432&lt;/strong&gt; (PostgreSQL) — Only accessed locally by Keycloak. No external access is needed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Since neither service is added with &lt;code&gt;--add-port&lt;/code&gt;, firewalld automatically rejects all inbound traffic to these ports. You can confirm this with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo firewall-cmd --list-ports
# Expected: empty or not showing 8080/5432
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Optional — Restrict Admin Console access to specific IPs/CIDRs.&lt;/strong&gt; If you want to limit who can access &lt;code&gt;/admin&lt;/code&gt; (e.g., only from your office network or VPN), use a rich rule on the firewalld zone facing the server&apos;s public IP:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ⚠️ Replace 203.0.113.0/24 with your actual trusted network CIDR
sudo firewall-cmd --permanent --zone=public --add-rich-rule=&apos;
  rule family=&quot;ipv4&quot; source address=&quot;203.0.113.0/24&quot; service name=&quot;https&quot; accept&apos;

sudo firewall-cmd --permanent --zone=public --remove-service=https
sudo firewall-cmd --reload
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; The commands above change HTTPS from &quot;open to all&quot; to &quot;only from defined source IPs.&quot; Only use this if all Keycloak users access from networks with predictable IPs (e.g., via VPN). If the CIDR is wrong, access will be cut off for everyone, including admins.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;9.2 SELinux&lt;/h3&gt;
&lt;p&gt;AlmaLinux 9 runs SELinux in &lt;code&gt;enforcing&lt;/code&gt; mode by default. To allow Nginx to proxy to the Keycloak backend (port 8080), enable the following boolean:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo setsebool -P httpd_can_network_connect on
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If other access denials appear in the logs, investigate with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo ausearch -m avc -ts recent
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;10. Verification&lt;/h2&gt;
&lt;p&gt;Open &lt;code&gt;https://your-domain.example.com/admin/&lt;/code&gt; in your browser and log in using the admin account created in step 6.2.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./dashboard-login-keycloak.png&quot; alt=&quot;Keycloak Login Page&quot; /&gt;&lt;/p&gt;
&lt;p&gt;After a successful login, you should see the Keycloak Administration Console:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./dashboard-utama-keycloak.png&quot; alt=&quot;Keycloak Administration Console&quot; /&gt;&lt;/p&gt;
&lt;p&gt;You can also verify the health endpoint (if &lt;code&gt;health-enabled=true&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -s http://127.0.0.1:8080/health/ready
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected response:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&quot;status&quot;: &quot;UP&quot;, &quot;checks&quot;: []}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Troubleshooting&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely Cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;systemctl status keycloak&lt;/code&gt; shows failure&lt;/td&gt;
&lt;td&gt;Check &lt;code&gt;journalctl -u keycloak -f&lt;/code&gt;; often caused by database connection not ready or port conflict&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;password authentication failed&lt;/code&gt; database error&lt;/td&gt;
&lt;td&gt;Verify &lt;code&gt;pg_hba.conf&lt;/code&gt; entries and make sure the password in &lt;code&gt;keycloak.conf&lt;/code&gt; matches the one set in step 3.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nginx 502 Bad Gateway&lt;/td&gt;
&lt;td&gt;Keycloak is not listening on &lt;code&gt;127.0.0.1:8080&lt;/code&gt;; check &lt;code&gt;http-host&lt;/code&gt;/&lt;code&gt;http-port&lt;/code&gt; in &lt;code&gt;keycloak.conf&lt;/code&gt; and the Keycloak service status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirect loop or &quot;invalid redirect_uri&quot; on client login&lt;/td&gt;
&lt;td&gt;The redirect URI configured in Keycloak does not exactly match the one sent by the application (watch for trailing slashes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Let&apos;s Encrypt certificate fails to issue&lt;/td&gt;
&lt;td&gt;Ensure DNS for the domain has propagated and port 80 is open when &lt;code&gt;certbot&lt;/code&gt; runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nginx cannot proxy despite correct configuration&lt;/td&gt;
&lt;td&gt;Likely blocked by SELinux; run &lt;code&gt;setsebool -P httpd_can_network_connect on&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;strong&gt;Summary:&lt;/strong&gt; Update system → Install OpenJDK 25 manually → Install Percona Distribution for PostgreSQL 17 → Download &amp;amp; build Keycloak 26.7.0 → Run as a systemd service → Expose via Nginx with a Let&apos;s Encrypt certificate → Manage port access with &lt;code&gt;firewall-cmd&lt;/code&gt; and adjust SELinux policies.&lt;/p&gt;
</content:encoded></item><item><title>Mailpit: A Self-Hosted Mailtrap Alternative for Email Testing</title><link>https://im-gatan.com/posts/mailpit-self-hosted-mailtrap-alternative/</link><guid isPermaLink="true">https://im-gatan.com/posts/mailpit-self-hosted-mailtrap-alternative/</guid><description>Learn how to set up Mailpit as a self-hosted, limit-free alternative to Mailtrap. We&apos;ll cover Docker Compose configuration, securing the Web UI, and setting up SMTP authentication.</description><pubDate>Sun, 05 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;If you frequently develop features that involve sending emails—such as password resets, notifications, invoices, and more—you are probably familiar with &lt;strong&gt;Mailtrap&lt;/strong&gt;. It is an incredibly useful tool because it intercepts emails sent from your application without actually delivering them to real mailboxes, allowing you to safely inspect them during development.&lt;/p&gt;
&lt;p&gt;However, Mailtrap&apos;s free plan has limitations (email volume, number of inboxes, retention, etc.). If you need a similar tool but want it self-hosted, completely free, and running entirely on your own local machine or staging server, &lt;strong&gt;Mailpit&lt;/strong&gt; is an excellent alternative.&lt;/p&gt;
&lt;h2&gt;What is Mailpit?&lt;/h2&gt;
&lt;p&gt;Mailpit is an open-source, lightweight, and fast email testing tool with minimal dependencies. Conceptually, Mailpit plays two roles at once:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SMTP Server&lt;/strong&gt; — Your application sends emails to Mailpit just like it would to a real SMTP provider, and Mailpit intercepts them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Web UI &amp;amp; API&lt;/strong&gt; — You can view intercepted emails through a modern web dashboard (supporting HTML, plain text, headers, raw source, and attachments) or fetch them via an API for automated testing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Since it runs as a single binary or a Docker image and doesn&apos;t require an external database, Mailpit is extremely easy to run in local development or staging environments.&lt;/p&gt;
&lt;h2&gt;Why Choose Mailpit as a Mailtrap Alternative?&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Mailtrap&lt;/th&gt;
&lt;th&gt;Mailpit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hosting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cloud (SaaS)&lt;/td&gt;
&lt;td&gt;Self-hosted (Docker)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cost&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Free with limits, paid for more&lt;/td&gt;
&lt;td&gt;Free, limited only by your server resources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Data Privacy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stored on third-party servers&lt;/td&gt;
&lt;td&gt;Stored on your own server or volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Setup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Sign up, create inbox, get credentials&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker compose up&lt;/code&gt; and it runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Authentication&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built-in / Required&lt;/td&gt;
&lt;td&gt;Configurable (explained below)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;For development and internal staging needs, Mailpit is a perfect fit because it gives you full control over your data without any subscription costs.&lt;/p&gt;
&lt;h2&gt;Docker Compose Setup&lt;/h2&gt;
&lt;p&gt;Here is the &lt;code&gt;docker-compose.yml&lt;/code&gt; configuration. It includes login protection for the Web UI and SMTP authentication (replicating Mailtrap&apos;s requirement for credentials):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  mailpit:
    image: ghcr.io/axllent/mailpit:v1.30.1@sha256:f35e6c915ecec9c0bb1837ee176554c695f4558eda25a6d82c1d30ba2145f769
    container_name: mailpit
    restart: unless-stopped
    volumes:
      - ./data:/data
      # Mount credentials file to protect UI and SMTP
      - ./auth.txt:/auth.txt:ro
    ports:
      - &quot;8025:8025&quot; # Web UI Dashboard
      - &quot;1025:1025&quot; # SMTP Port
    environment:
      MP_MAX_MESSAGES: 5000
      MP_DATABASE: /data/mailpit.db

      # 1. Lock the Web UI Dashboard (requires login like Mailtrap)
      MP_UI_AUTH_FILE: /auth.txt

      # 2. Require valid SMTP authentication from the auth file
      MP_SMTP_AUTH_FILE: /auth.txt

      # MP_SMTP_AUTH_ACCEPT_ANY: 1  # &amp;lt;-- Disable to enforce strict authentication

      # Allow plain-text login from the application (for internal network without SSL/TLS)
      MP_SMTP_AUTH_ALLOW_INSECURE: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Environment Variables Breakdown&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;MP_MAX_MESSAGES&lt;/code&gt; — The maximum number of emails stored before older ones are automatically pruned.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MP_DATABASE&lt;/code&gt; — The path to the SQLite database file where Mailpit stores emails.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MP_UI_AUTH_FILE&lt;/code&gt; — Enables Basic Auth on the Web UI, pointing to the credentials file.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MP_SMTP_AUTH_FILE&lt;/code&gt; — Requires applications to authenticate with a valid username/password, pointing to the same credentials file.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MP_SMTP_AUTH_ALLOW_INSECURE&lt;/code&gt; — Allows SMTP authentication over plain-text (non-TLS) connections. This is suitable for internal testing environments, but should be avoided if Mailpit is exposed to the public internet.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you want SMTP authentication enabled but don&apos;t care about validation (e.g., local development where any credentials should work), you can set &lt;code&gt;MP_SMTP_AUTH_ACCEPT_ANY: 1&lt;/code&gt; instead. Mailpit will then accept any username and password combination.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Generating the Authentication File (&lt;code&gt;auth.txt&lt;/code&gt;)&lt;/h2&gt;
&lt;p&gt;This is where people often run into issues. Mailpit &lt;strong&gt;does not&lt;/strong&gt; read plain-text passwords directly from the &lt;code&gt;auth.txt&lt;/code&gt; file. Instead, the passwords must be hashed (Mailpit supports formats like bcrypt, commonly used in standard &lt;code&gt;htpasswd&lt;/code&gt; files).&lt;/p&gt;
&lt;p&gt;The most practical way to generate this is using the &lt;code&gt;htpasswd&lt;/code&gt; utility from the &lt;code&gt;apache2-utils&lt;/code&gt; package (Debian/Ubuntu) or &lt;code&gt;httpd-tools&lt;/code&gt; (RHEL/CentOS).&lt;/p&gt;
&lt;h3&gt;1. Install &lt;code&gt;htpasswd&lt;/code&gt; (if not already installed)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Ubuntu/Debian
sudo apt install apache2-utils

# RHEL/CentOS/Fedora
sudo dnf install httpd-tools
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. Generate the Credentials&lt;/h3&gt;
&lt;p&gt;Create a new &lt;code&gt;auth.txt&lt;/code&gt; file with your first user using the &lt;code&gt;-c&lt;/code&gt; (create) and &lt;code&gt;-B&lt;/code&gt; (bcrypt) flags:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;htpasswd -B -c auth.txt test@mailer.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You will be prompted to enter and confirm a password:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;New password: 
Re-type new password: 
Adding password for user test@mailer.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you want to add additional users (e.g., for team members), run the command again &lt;strong&gt;without&lt;/strong&gt; the &lt;code&gt;-c&lt;/code&gt; flag so you don&apos;t overwrite the existing file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;htpasswd -B auth.txt dev2@mailer.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The resulting &lt;code&gt;auth.txt&lt;/code&gt; file will look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;test@mailer.com:$2y$05$examplehashedpasswordstringxxxxxxxxxxxxxxxxxxxxxx
dev2@mailer.com:$2y$05$anotherexamplehashxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save this file in the same directory as your &lt;code&gt;docker-compose.yml&lt;/code&gt;, then start the container:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose up -d
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. Using the Credentials&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Log in to the Web UI&lt;/strong&gt; at &lt;code&gt;http://localhost:8025&lt;/code&gt; using &lt;code&gt;test@mailer.com&lt;/code&gt; and the password you created.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Configure SMTP in your application&lt;/strong&gt; (e.g., in Laravel &lt;code&gt;.env&lt;/code&gt;, Node.js, etc.):&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_USERNAME=test@mailer.com
MAIL_PASSWORD=your_configured_password
MAIL_ENCRYPTION=null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since we enabled &lt;code&gt;MP_SMTP_AUTH_ALLOW_INSECURE=1&lt;/code&gt;, the application can authenticate without TLS, which is perfect for local or testing setups.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;With this setup, you now have a fully functional email testing environment running on your own infrastructure. It is secure, limit-free, and costs nothing. For small teams or personal projects, Mailpit is a incredibly solid replacement for Mailtrap.&lt;/p&gt;
&lt;p&gt;If you plan to expose Mailpit to the public internet, make sure to add a reverse proxy with HTTPS (such as Nginx or Caddy) in front of port 8025, and avoid using &lt;code&gt;MP_SMTP_AUTH_ALLOW_INSECURE&lt;/code&gt; in non-isolated networks.&lt;/p&gt;
</content:encoded></item><item><title>Setting Up ArgoCD with NGINX Ingress on a Bare-Metal Kubernetes Cluster</title><link>https://im-gatan.com/posts/argocd-setup-guide/</link><guid isPermaLink="true">https://im-gatan.com/posts/argocd-setup-guide/</guid><description>A complete guide to installing ArgoCD on a bare-metal Kubernetes cluster, configuring NGINX Ingress Controller for web access, and deploying your first applications using both the CLI and Web UI.</description><pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This guide walks you through setting up &lt;strong&gt;ArgoCD&lt;/strong&gt; — a declarative, GitOps-based continuous delivery tool — on a &lt;strong&gt;bare-metal Kubernetes cluster&lt;/strong&gt;. By the end, you&apos;ll have ArgoCD fully accessible through a web browser via an NGINX Ingress Controller, and you&apos;ll deploy sample applications using both the &lt;strong&gt;CLI&lt;/strong&gt; and the &lt;strong&gt;Web UI&lt;/strong&gt;.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; This guide assumes you already have a running Kubernetes cluster (e.g., set up with kubeadm). If you don&apos;t have one yet, check out my previous post on &lt;a href=&quot;/posts/kubernetes-single-host-setup/&quot;&gt;Kubernetes Single-Node Setup&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Table of Contents&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;#1-installing-argocd&quot;&gt;Installing ArgoCD&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-setting-up-nginx-ingress-controller&quot;&gt;Setting Up NGINX Ingress Controller&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-creating-an-ingress-for-argocd&quot;&gt;Creating an Ingress for ArgoCD&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#4-installing-the-argocd-cli&quot;&gt;Installing the ArgoCD CLI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#5-initial-login-and-password-setup&quot;&gt;Initial Login and Password Setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#6-accessing-the-argocd-web-ui&quot;&gt;Accessing the ArgoCD Web UI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#7-tuning-the-refresh-interval&quot;&gt;Tuning the Refresh Interval&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#8-deploying-an-application-via-cli&quot;&gt;Deploying an Application via CLI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#9-deploying-an-application-via-web-ui&quot;&gt;Deploying an Application via Web UI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#10-argocd-cli-tips&quot;&gt;ArgoCD CLI Tips&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;1. Installing ArgoCD&lt;/h2&gt;
&lt;h3&gt;1.1 Download the Installation Manifest&lt;/h3&gt;
&lt;p&gt;First, download the official ArgoCD installation manifest:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wget https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1.2 Configure a Custom Root Path (Optional)&lt;/h3&gt;
&lt;p&gt;If you want to access ArgoCD through a &lt;strong&gt;sub-path&lt;/strong&gt; (e.g., &lt;code&gt;http://&amp;lt;your-ip&amp;gt;/argocd-lab&lt;/code&gt;) instead of using a dedicated domain, you&apos;ll need to modify the &lt;code&gt;argocd-server&lt;/code&gt; Deployment in the downloaded &lt;code&gt;install.yaml&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Find the &lt;code&gt;Deployment&lt;/code&gt; resource named &lt;code&gt;argocd-server&lt;/code&gt; and add the &lt;code&gt;--rootpath&lt;/code&gt;, &lt;code&gt;--basehref&lt;/code&gt;, and &lt;code&gt;--insecure&lt;/code&gt; arguments:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app.kubernetes.io/component: server
    app.kubernetes.io/name: argocd-server
    app.kubernetes.io/part-of: argocd
  name: argocd-server
...
# Add these under spec.template.spec.containers[0].args
spec:
     containers:
     - args:
       - /usr/local/bin/argocd-server
       - --staticassets
       - /shared/app
       - --insecure
       - --rootpath
       - /argocd-lab
       - --basehref
       - /argocd-lab
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The path &lt;code&gt;/argocd-lab&lt;/code&gt; is used in this guide as an example to avoid conflicts with ArgoCD&apos;s default &lt;code&gt;/argocd&lt;/code&gt; path. Feel free to change it to whatever path suits your needs — just make sure it stays consistent across your Ingress configuration and CLI login commands. If you&apos;re using a dedicated domain or want ArgoCD on the root URL, you can skip the &lt;code&gt;--rootpath&lt;/code&gt; and &lt;code&gt;--basehref&lt;/code&gt; flags entirely.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;1.3 Create the Namespace and Deploy&lt;/h3&gt;
&lt;p&gt;Create the &lt;code&gt;argocd&lt;/code&gt; namespace and apply the manifest:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl create namespace argocd
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f install.yaml -n argocd
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;2. Setting Up NGINX Ingress Controller&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A Note on NGINX Ingress:&lt;/strong&gt; We are using NGINX Ingress Controller here because it is very easy to set up for demonstration purposes. However, if you are planning to deploy this in a production environment, you should consider alternatives that provide better long-term security support and active maintenance (such as Traefik, HAProxy, Contour, etc.).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important for bare-metal/on-prem clusters:&lt;/strong&gt; Clusters bootstrapped with &lt;strong&gt;kubeadm&lt;/strong&gt; don&apos;t come with a built-in Ingress Controller. Without one, any &lt;code&gt;Ingress&lt;/code&gt; resource you create won&apos;t work — &lt;code&gt;kubectl get ingress&lt;/code&gt; will show no &lt;code&gt;ADDRESS&lt;/code&gt;, and you&apos;ll get &lt;code&gt;connection refused&lt;/code&gt; errors when trying to access services.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;2.1 Check If an Ingress Controller Exists&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl get ingressclass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the output is &lt;code&gt;No resources found&lt;/code&gt;, you need to install one.&lt;/p&gt;
&lt;h3&gt;2.2 Install NGINX Ingress Controller&lt;/h3&gt;
&lt;p&gt;Since we&apos;re on bare-metal (no cloud LoadBalancer), install the NGINX Ingress Controller using the bare-metal manifest:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.3/deploy/static/provider/baremetal/deploy.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.3 Enable Host Networking&lt;/h3&gt;
&lt;p&gt;Edit the &lt;code&gt;ingress-nginx-controller&lt;/code&gt; Deployment to enable &lt;code&gt;hostNetwork&lt;/code&gt;, which allows the controller to bind directly to port 80/443 on the node — no external LoadBalancer or MetalLB required:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl edit deployment ingress-nginx-controller -n ingress-nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add &lt;code&gt;hostNetwork: true&lt;/code&gt; under &lt;code&gt;spec.template.spec&lt;/code&gt;, at the same level as &lt;code&gt;containers&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;spec:
  template:
    spec:
      hostNetwork: true
      containers:
      - name: controller
        ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.4 Verify the Installation&lt;/h3&gt;
&lt;p&gt;Wait a moment, then confirm the controller pod is running and the IngressClass is registered:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get pods -n ingress-nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;kubectl get ingressclass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME    CONTROLLER             PARAMETERS   AGE
nginx   k8s.io/ingress-nginx   &amp;lt;none&amp;gt;       30s
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;3. Creating an Ingress for ArgoCD&lt;/h2&gt;
&lt;p&gt;Create an &lt;code&gt;ingress.yaml&lt;/code&gt; file to expose ArgoCD through the &lt;code&gt;/argocd-lab&lt;/code&gt; path:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: argocd-server-ingress
  namespace: argocd
  annotations:
    ingress.kubernetes.io/ssl-redirect: &quot;false&quot;
spec:
  rules:
  - http:
      paths:
      - path: /argocd-lab
        pathType: Prefix
        backend:
          service:
            name: argocd-server
            port:
              number: 80
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apply it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f ingress.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;4. Installing the ArgoCD CLI&lt;/h2&gt;
&lt;p&gt;Download and install the ArgoCD CLI binary:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -SL -o argocd-linux-amd64 https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;sudo install -m 555 argocd-linux-amd64 /usr/local/bin/argocd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Clean up the downloaded file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rm argocd-linux-amd64
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify the installation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;argocd version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;argocd: v3.4.4+443415b
  BuildDate: 2026-06-18T09:15:00Z
  GitCommit: 443415b5527ac55366e0760c93ef0e1abd0cf273
  GitTreeState: clean
  GoVersion: go1.26.0
  Compiler: gc
  Platform: linux/amd64
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; You may see a &lt;code&gt;fatal&lt;/code&gt; message about the server address being unspecified — this is normal before logging in.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;5. Initial Login and Password Setup&lt;/h2&gt;
&lt;h3&gt;5.1 Retrieve the Default Admin Password&lt;/h3&gt;
&lt;p&gt;ArgoCD generates a random initial password for the &lt;code&gt;admin&lt;/code&gt; account during installation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;argocd admin initial-password -n argocd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[PASSWORD]

This password must be only used for first time login. We strongly recommend
you update the password using `argocd account update-password`.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.2 Log In via CLI&lt;/h3&gt;
&lt;p&gt;Log in using the generated password. Replace &lt;code&gt;&amp;lt;your-ip&amp;gt;&lt;/code&gt; with your node&apos;s IP address:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;argocd login &amp;lt;your-ip&amp;gt;:80 --grpc-web-root-path=/argocd-lab --plaintext --insecure
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When prompted, enter &lt;code&gt;admin&lt;/code&gt; as the username and the password from the previous step.&lt;/p&gt;
&lt;h3&gt;5.3 Change the Admin Password&lt;/h3&gt;
&lt;p&gt;For security, update the default password immediately:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;argocd account update-password
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;*** Enter password of currently logged in user (admin):
*** Enter new password for user admin:
*** Confirm new password for user admin:
Password updated
Context &apos;&amp;lt;your-ip&amp;gt;:80/argocd-lab&apos; updated
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;6. Accessing the ArgoCD Web UI&lt;/h2&gt;
&lt;p&gt;Open your browser and navigate to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;http://&amp;lt;your-ip&amp;gt;/argocd-lab
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Log in with the &lt;code&gt;admin&lt;/code&gt; username and the password you just set.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./argocd-login.png&quot; alt=&quot;ArgoCD Login Page&quot; /&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7. Tuning the Refresh Interval&lt;/h2&gt;
&lt;p&gt;By default, ArgoCD polls your Git repositories every &lt;strong&gt;3 minutes&lt;/strong&gt; to check for changes. If you want faster feedback (e.g., during development), you can lower this interval.&lt;/p&gt;
&lt;p&gt;Create a &lt;code&gt;config.yaml&lt;/code&gt; file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-cm
  namespace: argocd
  labels:
    app.kubernetes.io/name: argocd-cm
    app.kubernetes.io/part-of: argocd
data:
  timeout.reconciliation: 20s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apply the configuration:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl apply -f config.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify the change:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl describe configmap -n argocd argocd-cm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Restart the repo server to pick up the new setting:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl -n argocd rollout restart deploy argocd-repo-server
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; A 20-second interval is great for development and testing, but for production environments you may want to keep it at the default 3 minutes (or higher) to reduce load on both ArgoCD and your Git server.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;8. Deploying an Application via CLI&lt;/h2&gt;
&lt;h3&gt;8.1 Create the Application&lt;/h3&gt;
&lt;p&gt;Create a sample application called &lt;code&gt;simple-deploy&lt;/code&gt; pointing to a Git repository:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;argocd app create simple-deploy \
  --repo https://github.com/maliqpotter/argo-simple-project.git \
  --path simple-demo \
  --dest-server https://kubernetes.default.svc \
  --dest-namespace default
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;application &apos;simple-deploy&apos; created
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;8.2 Check the Application Status&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;argocd app get simple-deploy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The initial status will be &lt;code&gt;OutOfSync&lt;/code&gt; because no Kubernetes resources have been created yet:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Name:               argocd/simple-deploy
Project:            default
Server:             https://kubernetes.default.svc
Namespace:          default
Source:
- Repo:             https://github.com/maliqpotter/argo-simple-project.git
  Path:             simple-demo
Sync Status:        OutOfSync from  (1873ef1)
Health Status:      Missing

GROUP  KIND        NAMESPACE  NAME          STATUS     HEALTH   HOOK  MESSAGE
apps   Deployment  default    simple-nginx  OutOfSync  Missing
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;8.3 Sync (Deploy) the Application&lt;/h3&gt;
&lt;p&gt;Trigger a sync to deploy the application:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;argocd app sync simple-deploy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once the sync completes, verify the status:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;argocd app get simple-deploy
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Name:               argocd/simple-deploy
Project:            default
Server:             https://kubernetes.default.svc
Namespace:          default
Source:
- Repo:             https://github.com/maliqpotter/argo-simple-project.git
  Path:             simple-demo
Sync Policy:        Manual
Sync Status:        Synced to  (1873ef1)
Health Status:      Healthy

GROUP  KIND        NAMESPACE  NAME          STATUS  HEALTH   HOOK  MESSAGE
apps   Deployment  default    simple-nginx  Synced  Healthy        deployment.apps/simple-nginx created
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;8.4 Verify the Resources&lt;/h3&gt;
&lt;p&gt;Check that the pods and deployment were created:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl get pod -n default
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;NAME                             READY   STATUS    RESTARTS   AGE
simple-deploy-5d7468b6f8-75w68   1/1     Running   0          2m11s
simple-deploy-5d7468b6f8-mkkph   1/1     Running   0          2m11s
simple-deploy-5d7468b6f8-mkgdz   1/1     Running   0          2m11s
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;kubectl get deployment -n default
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;NAME            READY   UP-TO-DATE   AVAILABLE   AGE
simple-deploy   3/3     3            3           2m38s
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;9. Deploying an Application via Web UI&lt;/h2&gt;
&lt;p&gt;In addition to the CLI, you can create and deploy applications directly from the ArgoCD dashboard.&lt;/p&gt;
&lt;h3&gt;9.1 Open the Dashboard&lt;/h3&gt;
&lt;p&gt;After logging in, you&apos;ll see the ArgoCD dashboard showing your existing applications (like the &lt;code&gt;simple-deploy&lt;/code&gt; app we just created via CLI).&lt;/p&gt;
&lt;p&gt;Click the &lt;strong&gt;&lt;code&gt;+ NEW APP&lt;/code&gt;&lt;/strong&gt; button to create a new application.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./argocd-dashboard.png&quot; alt=&quot;ArgoCD Dashboard with NEW APP button highlighted&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;9.2 Fill in the General Settings&lt;/h3&gt;
&lt;p&gt;Under the &lt;strong&gt;GENERAL&lt;/strong&gt; section, configure:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Application Name&lt;/strong&gt;: &lt;code&gt;nginx-with-ingress&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Project Name&lt;/strong&gt;: &lt;code&gt;default&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sync Policy&lt;/strong&gt;: &lt;code&gt;Manual&lt;/code&gt; (or &lt;code&gt;Automatic&lt;/code&gt; if you want auto-sync)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;./argocd-new-app-general.png&quot; alt=&quot;ArgoCD New App - General Configuration&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;9.3 Configure the Source and Destination&lt;/h3&gt;
&lt;p&gt;Under the &lt;strong&gt;SOURCE&lt;/strong&gt; section:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Repository URL&lt;/strong&gt;: &lt;code&gt;https://github.com/maliqpotter/argo-simple-project.git&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Revision&lt;/strong&gt;: &lt;code&gt;HEAD&lt;/code&gt; (or the specific branch you want, e.g., &lt;code&gt;main&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Path&lt;/strong&gt;: &lt;code&gt;nginx-with-ingress&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Under the &lt;strong&gt;DESTINATION&lt;/strong&gt; section:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cluster URL&lt;/strong&gt;: &lt;code&gt;https://kubernetes.default.svc&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Namespace&lt;/strong&gt;: &lt;code&gt;default&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then click the &lt;strong&gt;&lt;code&gt;CREATE&lt;/code&gt;&lt;/strong&gt; button at the top of the form.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./argocd-new-app-source.png&quot; alt=&quot;ArgoCD New App - Source and Destination Configuration&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;9.4 Sync the Application&lt;/h3&gt;
&lt;p&gt;Once the &lt;code&gt;nginx-with-ingress&lt;/code&gt; application appears on the dashboard with an &lt;code&gt;OutOfSync&lt;/code&gt; status, click the &lt;strong&gt;&lt;code&gt;SYNC&lt;/code&gt;&lt;/strong&gt; button on the application card, then click &lt;strong&gt;&lt;code&gt;SYNCHRONIZE&lt;/code&gt;&lt;/strong&gt; to confirm.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;./argocd-sync-app.png&quot; alt=&quot;ArgoCD Dashboard showing the Sync button&quot; /&gt;&lt;/p&gt;
&lt;p&gt;After the sync completes, the application status will change to &lt;code&gt;Synced&lt;/code&gt; and &lt;code&gt;Healthy&lt;/code&gt;. You can click into the application to see a visual map of all the Kubernetes resources that were created (Deployments, ReplicaSets, Pods, etc.).&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;10. ArgoCD CLI Tips&lt;/h2&gt;
&lt;h3&gt;10.1 Set Default Namespace&lt;/h3&gt;
&lt;p&gt;Since ArgoCD is installed in the &lt;code&gt;argocd&lt;/code&gt; namespace, you can set it as your default to avoid typing &lt;code&gt;-n argocd&lt;/code&gt; on every command:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option A — Set the kubectl context:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl config set-context --current --namespace=argocd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Option B — Use an environment variable:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export ARGOCD_OPTS=&apos;--port-forward-namespace argocd&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;10.2 Enable Bash Completion&lt;/h3&gt;
&lt;p&gt;Speed up your CLI workflow with tab completion:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;argocd completion bash | sudo tee /etc/bash_completion.d/argocd
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;source ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://argo-cd.readthedocs.io/en/stable/&quot;&gt;ArgoCD Official Documentation — argoproj.github.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://kubernetes.github.io/ingress-nginx/&quot;&gt;NGINX Ingress Controller — kubernetes.github.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://argo-cd.readthedocs.io/en/stable/user-guide/commands/argocd/&quot;&gt;ArgoCD CLI Reference — argo-cd.readthedocs.io&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This guide is based on a real-world deployment. Adjust IPs, paths, and version numbers to match your own environment.&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>Kubernetes Single-Node Setup on AlmaLinux 9.8 with Kubeadm, Containerd &amp; Calico</title><link>https://im-gatan.com/posts/kubernetes-single-host-setup/</link><guid isPermaLink="true">https://im-gatan.com/posts/kubernetes-single-host-setup/</guid><description>A step-by-step guide to deploying a single-node Kubernetes cluster using Kubeadm, containerd as the container runtime, and Tigera Calico as the CNI plugin on AlmaLinux 9.8.</description><pubDate>Fri, 19 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This guide walks you through setting up a fully functional &lt;strong&gt;single-node Kubernetes cluster&lt;/strong&gt; using &lt;strong&gt;Kubeadm&lt;/strong&gt;, with &lt;strong&gt;containerd&lt;/strong&gt; as the container runtime and &lt;strong&gt;Tigera Calico&lt;/strong&gt; as the CNI plugin — all running on &lt;strong&gt;AlmaLinux 9.8 (Olive Jaguar)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;By the end, you&apos;ll have a working Kubernetes node capable of scheduling and running pods, complete with networking provided by Calico.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;Server Specifications&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;AlmaLinux 9.8 (Olive Jaguar)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4 vCPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;10 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Container Runtime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;containerd&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CNI Plugin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tigera Calico&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Installer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;kubeadm&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;Table of Contents&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;#1-system-preparation&quot;&gt;System Preparation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-installing-containerd&quot;&gt;Installing containerd&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-configuring-containerd-for-kubernetes&quot;&gt;Configuring containerd for Kubernetes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#4-installing-kubeadm-kubelet-kubectl&quot;&gt;Installing kubeadm, kubelet, kubectl&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#5-initializing-the-cluster-with-kubeadm&quot;&gt;Initializing the Cluster with kubeadm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#6-configuring-kubectl&quot;&gt;Configuring kubectl&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#7-installing-tigera-calico&quot;&gt;Installing Tigera Calico&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#8-verifying-the-cluster&quot;&gt;Verifying the Cluster&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;1. System Preparation&lt;/h2&gt;
&lt;h3&gt;1.1 Set Hostname&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;hostnamectl set-hostname &amp;lt;your-hostname&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1.2 Disable Swap&lt;/h3&gt;
&lt;p&gt;Kubernetes requires swap to be disabled.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Disable swap immediately
swapoff -a

# Disable swap permanently
sed -i &apos;/ swap / s/^\(.*\)$/#\1/g&apos; /etc/fstab
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1.3 Disable SELinux&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Disable temporarily
setenforce 0

# Disable permanently
sed -i &apos;s/^SELINUX=enforcing$/SELINUX=permissive/&apos; /etc/selinux/config
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1.4 Disable Firewall (or Configure Required Ports)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;systemctl disable --now firewalld
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; If you prefer to keep the firewall active, open the following ports instead:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;6443/tcp&lt;/code&gt; — Kubernetes API Server&lt;/li&gt;
&lt;li&gt;&lt;code&gt;2379-2380/tcp&lt;/code&gt; — etcd server client API&lt;/li&gt;
&lt;li&gt;&lt;code&gt;10250/tcp&lt;/code&gt; — kubelet API&lt;/li&gt;
&lt;li&gt;&lt;code&gt;10251/tcp&lt;/code&gt; — kube-scheduler&lt;/li&gt;
&lt;li&gt;&lt;code&gt;10252/tcp&lt;/code&gt; — kube-controller-manager&lt;/li&gt;
&lt;li&gt;&lt;code&gt;179/tcp&lt;/code&gt; — Calico BGP&lt;/li&gt;
&lt;li&gt;&lt;code&gt;4789/udp&lt;/code&gt; — Calico VXLAN (optional)&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h3&gt;1.5 Load Required Kernel Modules&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Create module configuration
cat &amp;lt;&amp;lt;EOF | tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

# Load modules immediately
modprobe overlay
modprobe br_netfilter
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1.6 Configure Sysctl Parameters&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cat &amp;lt;&amp;lt;EOF | tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF

# Apply the configuration
sysctl --system
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1.7 Verify Kernel Modules Are Loaded&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;lsmod | grep br_netfilter
lsmod | grep overlay
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output (values may vary):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;br_netfilter           36864  0
bridge                421888  1 br_netfilter
overlay               237568  0
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;2. Installing containerd&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Reference:&lt;/strong&gt; &lt;a href=&quot;https://docs.docker.com/engine/install/rhel/&quot;&gt;Docker Engine on RHEL — docs.docker.com&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Since we only need &lt;strong&gt;containerd&lt;/strong&gt; (not the full Docker Engine), we&apos;ll use the Docker repository to install just the &lt;code&gt;containerd.io&lt;/code&gt; package.&lt;/p&gt;
&lt;h3&gt;2.1 Remove Potentially Conflicting Packages&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;dnf remove -y docker \
    docker-client \
    docker-client-latest \
    docker-common \
    docker-latest \
    docker-latest-logrotate \
    docker-logrotate \
    docker-engine \
    podman \
    runc
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.2 Install DNF Plugin and Add Docker Repository&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;dnf -y install dnf-plugins-core
dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.3 Install containerd.io&lt;/h3&gt;
&lt;p&gt;Only install the &lt;code&gt;containerd.io&lt;/code&gt; package — no need for docker-ce or other Docker components.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;dnf install -y containerd.io
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.4 Enable and Start containerd&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;systemctl enable --now containerd
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.5 Verify containerd&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;systemctl status containerd
containerd --version
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;3. Configuring containerd for Kubernetes&lt;/h2&gt;
&lt;h3&gt;3.1 Generate Default Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p /etc/containerd
containerd config default | tee /etc/containerd/config.toml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.2 Enable SystemdCgroup&lt;/h3&gt;
&lt;p&gt;Kubernetes requires containerd to use &lt;code&gt;systemd&lt;/code&gt; as the cgroup driver.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sed -i &apos;s/SystemdCgroup = false/SystemdCgroup = true/&apos; /etc/containerd/config.toml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify the change:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;grep &apos;SystemdCgroup&apos; /etc/containerd/config.toml
# Expected output: SystemdCgroup = true
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.3 Restart containerd&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;systemctl restart containerd
systemctl status containerd
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;4. Installing kubeadm, kubelet, kubectl&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Reference:&lt;/strong&gt; &lt;a href=&quot;https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/&quot;&gt;Installing kubeadm — kubernetes.io&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;4.1 Add the Kubernetes Repository&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cat &amp;lt;&amp;lt;EOF | sudo tee /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.36/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.36/rpm/repodata/repomd.xml.key
exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Replace &lt;code&gt;v1.36&lt;/code&gt; with your desired Kubernetes version. Check the latest releases at &lt;a href=&quot;https://kubernetes.io/releases/&quot;&gt;kubernetes.io/releases&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;4.2 Install kubelet, kubeadm, kubectl&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;dnf install -y kubelet kubeadm kubectl --disableexcludes=kubernetes
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.3 Enable kubelet&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;systemctl enable --now kubelet
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; kubelet will enter a restart loop until &lt;code&gt;kubeadm init&lt;/code&gt; completes — this is expected behavior.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;4.4 Verify Versions&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubeadm version
kubectl version --client
kubelet --version
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;5. Initializing the Cluster with kubeadm&lt;/h2&gt;
&lt;h3&gt;5.1 Initialize the Control Plane&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubeadm init \
  --pod-network-cidr=10.244.0.0/16 \
  --cri-socket unix:///run/containerd/containerd.sock
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;--pod-network-cidr=10.244.0.0/16&lt;/code&gt; flag is used here because some server networks (e.g., &lt;code&gt;192.168.x.x/24&lt;/code&gt;) may overlap with Calico&apos;s default CIDR (&lt;code&gt;192.168.0.0/16&lt;/code&gt;). Using &lt;code&gt;10.244.0.0/16&lt;/code&gt; avoids routing conflicts.&lt;/li&gt;
&lt;li&gt;If your server has multiple network interfaces, add &lt;code&gt;--apiserver-advertise-address=&amp;lt;your-server-ip&amp;gt;&lt;/code&gt; to specify which IP the API server should bind to.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;Make sure to save the &lt;code&gt;kubeadm join&lt;/code&gt; command from the output — you&apos;ll need it if you ever want to add worker nodes.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. Configuring kubectl&lt;/h2&gt;
&lt;h3&gt;6.1 Set Up kubeconfig for Root User&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p $HOME/.kube
cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
chown $(id -u):$(id -g) $HOME/.kube/config
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.2 Verify the Node (NotReady Is Expected)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME              STATUS     ROLES           AGE   VERSION
&amp;lt;your-hostname&amp;gt;   NotReady   control-plane   Xs    v1.36.x
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;NotReady&lt;/code&gt; status is expected because the CNI plugin (Calico) hasn&apos;t been installed yet.&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7. Installing Tigera Calico&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Reference:&lt;/strong&gt; &lt;a href=&quot;https://docs.tigera.io/calico/latest/getting-started/kubernetes/k8s-single-node&quot;&gt;Calico on single-host Kubernetes — docs.tigera.io&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;7.1 Install the Tigera Operator&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.32.0/manifests/tigera-operator.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.2 Create the Custom Resource for Calico&lt;/h3&gt;
&lt;p&gt;Since we&apos;re using CIDR &lt;code&gt;10.244.0.0/16&lt;/code&gt; (instead of the default &lt;code&gt;192.168.0.0/16&lt;/code&gt;), download the manifest first, update the CIDR, then apply:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -O https://raw.githubusercontent.com/projectcalico/calico/v3.32.0/manifests/custom-resources.yaml

# Replace Calico&apos;s default CIDR with ours
sed -i &apos;s|192.168.0.0/16|10.244.0.0/16|g&apos; custom-resources.yaml

# Verify the change
grep &apos;cidr&apos; custom-resources.yaml

# Apply the configuration
kubectl create -f custom-resources.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.3 Wait for Calico to Deploy&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;watch kubectl get pods -n calico-system
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Wait until all pods show &lt;code&gt;Running&lt;/code&gt; status:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME                                       READY   STATUS    RESTARTS   AGE
calico-kube-controllers-xxx                1/1     Running   0          Xm
calico-node-xxx                            1/1     Running   0          Xm
calico-typha-xxx                           1/1     Running   0          Xm
csi-node-driver-xxx                        2/2     Running   0          Xm
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Press &lt;code&gt;Ctrl+C&lt;/code&gt; to exit watch.&lt;/p&gt;
&lt;h3&gt;7.4 Untaint the Control Plane Node&lt;/h3&gt;
&lt;p&gt;By default, Kubernetes prevents workloads from being scheduled on control plane nodes. For a single-node setup, we need to remove this taint:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl taint nodes --all node-role.kubernetes.io/control-plane-
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;8. Verifying the Cluster&lt;/h2&gt;
&lt;h3&gt;8.1 Check Node Status&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl get nodes -o wide
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The node status should now show &lt;code&gt;Ready&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME              STATUS   ROLES           AGE   VERSION   INTERNAL-IP     OS-IMAGE                      CONTAINER-RUNTIME
&amp;lt;your-hostname&amp;gt;   Ready    control-plane   Xm    v1.36.x   &amp;lt;your-ip&amp;gt;      AlmaLinux 9.8 (Olive Jaguar)  containerd://2.x.x
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;8.2 Check All System Pods&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl get pods -A
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All pods across &lt;code&gt;kube-system&lt;/code&gt;, &lt;code&gt;calico-system&lt;/code&gt;, and &lt;code&gt;tigera-operator&lt;/code&gt; namespaces should show &lt;code&gt;Running&lt;/code&gt; status.&lt;/p&gt;
&lt;h3&gt;8.3 Test with a Simple Deployment&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl run nginx --image=nginx --port=80
kubectl get pods
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NAME    READY   STATUS    RESTARTS   AGE
nginx   1/1     Running   0          15s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify the pod details:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl describe pod nginx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The pod should be scheduled on your node with a Calico-assigned IP from the &lt;code&gt;10.244.x.x&lt;/code&gt; range.&lt;/p&gt;
&lt;h3&gt;8.4 Check Cluster Information&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kubectl cluster-info
kubectl version
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;Troubleshooting&lt;/h2&gt;
&lt;h3&gt;Node remains NotReady after installing Calico&lt;/h3&gt;
&lt;p&gt;Inspect the node events and Calico logs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl describe node &amp;lt;your-hostname&amp;gt;
kubectl logs -n calico-system -l k8s-app=calico-node
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Pod stuck in ContainerCreating&lt;/h3&gt;
&lt;p&gt;Check the pod events and kubelet logs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubectl describe pod &amp;lt;pod-name&amp;gt;
journalctl -u kubelet -f
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Reset the cluster (start over)&lt;/h3&gt;
&lt;p&gt;If you need to completely reset and start fresh:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kubeadm reset -f
rm -rf /etc/cni/net.d
rm -rf $HOME/.kube
iptables -F &amp;amp;&amp;amp; iptables -t nat -F &amp;amp;&amp;amp; iptables -t mangle -F &amp;amp;&amp;amp; iptables -X
ipvsadm --clear 2&amp;gt;/dev/null || true
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.docker.com/engine/install/rhel/&quot;&gt;Docker Engine on RHEL — docs.docker.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/&quot;&gt;Installing kubeadm — kubernetes.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.tigera.io/calico/latest/getting-started/kubernetes/k8s-single-node&quot;&gt;Calico on single-host Kubernetes — docs.tigera.io&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This guide is based on a real-world deployment. Adjust hostnames, IPs, and version numbers to match your own environment.&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>Deploying OpenStack with Ceph Tentacle: A 3-Node Converged Setup Guide</title><link>https://im-gatan.com/posts/openstack-ceph-deployment/</link><guid isPermaLink="true">https://im-gatan.com/posts/openstack-ceph-deployment/</guid><description>A complete step-by-step guide to deploying OpenStack (master/2025.2) with Ceph Tentacle as the distributed storage backend on a 3-node converged cluster using Kolla-Ansible and Ubuntu Noble 24.04.</description><pubDate>Thu, 18 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In this guide, I&apos;ll walk you through deploying a fully functional &lt;strong&gt;OpenStack&lt;/strong&gt; cloud platform integrated with &lt;strong&gt;Ceph Tentacle&lt;/strong&gt; as the distributed storage backend — all on a 3-node converged cluster running &lt;strong&gt;Ubuntu Noble 24.04&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&amp;lt;div style=&quot;display: flex; align-items: center; gap: 2rem; margin: 1.5rem 0;&quot;&amp;gt;
&amp;lt;img src=&quot;/images/blog/ceph-logo.png&quot; alt=&quot;Ceph Logo&quot; style=&quot;height: 120px; width: auto;&quot; /&amp;gt;
&amp;lt;span style=&quot;font-size: 2rem; opacity: 0.4;&quot;&amp;gt;+&amp;lt;/span&amp;gt;
&amp;lt;img src=&quot;/images/blog/openstack-logo.png&quot; alt=&quot;OpenStack Logo&quot; style=&quot;height: 120px; width: auto;&quot; /&amp;gt;
&amp;lt;/div&amp;gt;&lt;/p&gt;
&lt;p&gt;This setup uses &lt;strong&gt;Kolla-Ansible&lt;/strong&gt; for deploying OpenStack services as Docker containers, and &lt;strong&gt;cephadm&lt;/strong&gt; for bootstrapping the Ceph cluster. By the end of this guide, you&apos;ll have a working private cloud with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;☁️ &lt;strong&gt;OpenStack&lt;/strong&gt; — Keystone, Glance, Nova, Neutron, Cinder, Horizon&lt;/li&gt;
&lt;li&gt;💾 &lt;strong&gt;Ceph&lt;/strong&gt; — Distributed storage for images, volumes, VMs, and backups&lt;/li&gt;
&lt;li&gt;🌐 &lt;strong&gt;Networking&lt;/strong&gt; — Provider + tenant networks with floating IPs&lt;/li&gt;
&lt;li&gt;🖥️ &lt;strong&gt;VM Instances&lt;/strong&gt; — Running CirrOS test instances with SSH access&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Table of Contents&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;#1-topology--specifications&quot;&gt;Topology &amp;amp; Specifications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#2-node-preparation&quot;&gt;Node Preparation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#3-deploy-ceph-tentacle-with-cephadm&quot;&gt;Deploy Ceph Tentacle with cephadm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#4-ceph-pool--keyring-setup-for-openstack&quot;&gt;Ceph Pool &amp;amp; Keyring Setup for OpenStack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#5-install-kolla-ansible&quot;&gt;Install Kolla-Ansible&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#6-kolla-ansible-configuration&quot;&gt;Kolla-Ansible Configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#7-external-ceph-integration-with-kolla-ansible&quot;&gt;External Ceph Integration with Kolla-Ansible&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#8-deploy-openstack&quot;&gt;Deploy OpenStack&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#9-openstack-network-setup&quot;&gt;OpenStack Network Setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#10-create-vm-instance&quot;&gt;Create VM Instance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#11-verify-vm-connectivity&quot;&gt;Verify VM Connectivity&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;1. Topology &amp;amp; Specifications&lt;/h2&gt;
&lt;h3&gt;Network Topology&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;                    ┌──────────────────┐
                    │   Proxmox Host   │
                    │  vmbr0 (eno1)    │
                    │ 192.168.205.253  │
                    └────────┬─────────┘
           ┌─────────────────┼─────────────────┐
           │                 │                 │
    ens18 (Mgmt)       ens18 (Mgmt)       ens18 (Mgmt)
  192.168.205.101    192.168.205.102    192.168.205.103
    ens19 (Provider)   ens19 (Provider)   ens19 (Provider)
    (no IP)            (no IP)            (no IP)
           │                 │                 │
  ┌─────────────────┐ ┌──────────────┐ ┌──────────────┐
  │  ops-ceph-ctrl  │ │ops-ceph-comp1│ │ops-ceph-comp2│
  │  (controller)   │ │  (compute)   │ │  (compute)   │
  │                 │ │              │ │              │
  │ /dev/sda (OS)   │ │ /dev/sda(OS) │ │ /dev/sda(OS) │
  │ /dev/sdb OSD.0  │ │ /dev/sdb OSD │ │ /dev/sdb OSD │
  │ /dev/sdc OSD.1  │ │ /dev/sdc OSD │ │ /dev/sdc OSD │
  │ /dev/sdd OSD.2  │ │ /dev/sdd OSD │ │ /dev/sdd OSD │
  └─────────────────┘ └──────────────┘ └──────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Node Specifications&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Parameter&lt;/th&gt;
&lt;th&gt;ops-ceph-ctrl&lt;/th&gt;
&lt;th&gt;ops-ceph-comp1&lt;/th&gt;
&lt;th&gt;ops-ceph-comp2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;8 vCPU&lt;/td&gt;
&lt;td&gt;6 vCPU&lt;/td&gt;
&lt;td&gt;6 vCPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RAM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;16 GB&lt;/td&gt;
&lt;td&gt;12 GB&lt;/td&gt;
&lt;td&gt;12 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Disk OS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;50 GB &lt;code&gt;/dev/sda&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;50 GB &lt;code&gt;/dev/sda&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;50 GB &lt;code&gt;/dev/sda&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Disk OSD&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3x 25 GB (&lt;code&gt;/dev/sdb&lt;/code&gt;,&lt;code&gt;sdc&lt;/code&gt;,&lt;code&gt;sdd&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;3x 25 GB&lt;/td&gt;
&lt;td&gt;3x 25 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NIC 1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ens18&lt;/code&gt; → 192.168.205.101/24&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ens18&lt;/code&gt; → 192.168.205.102/24&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ens18&lt;/code&gt; → 192.168.205.103/24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NIC 2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ens19&lt;/code&gt; → no IP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ens19&lt;/code&gt; → no IP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ens19&lt;/code&gt; → no IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Ubuntu Noble 24.04&lt;/td&gt;
&lt;td&gt;Ubuntu Noble 24.04&lt;/td&gt;
&lt;td&gt;Ubuntu Noble 24.04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ceph Role&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MON, MGR, OSD&lt;/td&gt;
&lt;td&gt;OSD&lt;/td&gt;
&lt;td&gt;OSD&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OpenStack Role&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Controller, Network&lt;/td&gt;
&lt;td&gt;Compute&lt;/td&gt;
&lt;td&gt;Compute&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;2. Node Preparation&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;Run the following steps on &lt;strong&gt;all nodes&lt;/strong&gt; unless stated otherwise.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;2.1 Set Hostname&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# On ops-ceph-ctrl
hostnamectl set-hostname ops-ceph-ctrl

# On ops-ceph-comp1
hostnamectl set-hostname ops-ceph-comp1

# On ops-ceph-comp2
hostnamectl set-hostname ops-ceph-comp2
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.2 Configure /etc/hosts&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo nano /etc/hosts
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add on all nodes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;192.168.205.101  ops-ceph-ctrl
192.168.205.102  ops-ceph-comp1
192.168.205.103  ops-ceph-comp2
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.3 Configure Netplan&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo nano /etc/netplan/50-cloud-init.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Example for &lt;strong&gt;ops-ceph-ctrl&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;network:
  version: 2
  renderer: networkd
  ethernets:
    ens18:
      addresses:
        - 192.168.205.101/24
      routes:
        - to: default
          via: 192.168.205.1
      nameservers:
        addresses: [8.8.8.8, 1.1.1.1]
    ens19:
      dhcp4: false
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;Adjust IP for comp1 (&lt;code&gt;192.168.205.102&lt;/code&gt;) and comp2 (&lt;code&gt;192.168.205.103&lt;/code&gt;). &lt;code&gt;ens19&lt;/code&gt; only needs &lt;code&gt;dhcp4: false&lt;/code&gt; without an IP — Neutron will use it as the provider interface.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;sudo netplan apply
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.4 Install Dependencies&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update
sudo apt install -y git curl wget python3-dev python3-venv \
  libffi-dev gcc libssl-dev libdbus-glib-1-dev chrony sshpass nano
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.5 Configure Passwordless SSH (from ops-ceph-ctrl)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Generate SSH key (ed25519)
ssh-keygen -t ed25519 -C &quot;ops-ceph-ctrl&quot;

# Distribute public key to all nodes including self
ssh-copy-id opsce@ops-ceph-ctrl
ssh-copy-id opsce@ops-ceph-comp1
ssh-copy-id opsce@ops-ceph-comp2
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.6 Set NOPASSWD sudo (all nodes)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;echo &quot;opsce ALL=(ALL) NOPASSWD: ALL&quot; | sudo tee /etc/sudoers.d/opsce
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.7 Disable Swap &amp;amp; Time Sync&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo swapoff -a
sudo sed -i &apos;/swap/d&apos; /etc/fstab

sudo timedatectl set-timezone Asia/Jakarta
sudo systemctl enable --now chrony
chronyc tracking
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.8 Sysctl Configuration&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo nano /etc/sysctl.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Add:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;net.ipv4.ip_forward = 1
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;sudo sysctl -p
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.9 Install Docker&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;curl -sL https://get.docker.com | bash
sudo systemctl enable --now docker
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;3. Deploy Ceph Tentacle with cephadm&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;All steps are executed &lt;strong&gt;from ops-ceph-ctrl&lt;/strong&gt; unless stated otherwise.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;3.1 Install cephadm&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;curl --silent --remote-name --location \
  https://download.ceph.com/rpm-tentacle/el9/noarch/cephadm

chmod +x cephadm
sudo install -m 0755 cephadm /usr/local/sbin/cephadm

sudo cephadm add-repo --release tentacle
sudo apt update
sudo apt install -y cephadm
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.2 Bootstrap Ceph Cluster&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo cephadm bootstrap \
  --mon-ip &amp;lt;your-controller-ip&amp;gt; \
  --cluster-network &amp;lt;your-subnet&amp;gt;/24 \
  --initial-dashboard-user &amp;lt;dashboard-user&amp;gt; \
  --initial-dashboard-password &amp;lt;dashboard-password&amp;gt; \
  --allow-overwrite
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo cephadm shell -- ceph -s
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.3 Install ceph-common&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# On ctrl
sudo cephadm install ceph-common

# On comp1 and comp2
ssh -t ops-ceph-comp1 &quot;sudo cephadm add-repo --release tentacle &amp;amp;&amp;amp; sudo apt update &amp;amp;&amp;amp; sudo apt install -y ceph-common&quot;
ssh -t ops-ceph-comp2 &quot;sudo cephadm add-repo --release tentacle &amp;amp;&amp;amp; sudo apt update &amp;amp;&amp;amp; sudo apt install -y ceph-common&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.4 Copy cephadm SSH Key to All Nodes&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo cephadm shell -- ceph cephadm get-pub-key | \
  sudo tee /etc/ceph/ceph.pub

# Must run as root
sudo su
ssh-copy-id -f -i /etc/ceph/ceph.pub root@ops-ceph-comp1
ssh-copy-id -f -i /etc/ceph/ceph.pub root@ops-ceph-comp2
exit
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.5 Add Nodes to Cluster&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo cephadm shell -- ceph orch host add ops-ceph-comp1 192.168.205.102
sudo cephadm shell -- ceph orch host add ops-ceph-comp2 192.168.205.103

# Verify
sudo cephadm shell -- ceph orch host ls
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.6 Deploy OSD&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# ops-ceph-ctrl
sudo cephadm shell -- ceph orch daemon add osd ops-ceph-ctrl:/dev/sdb
sudo cephadm shell -- ceph orch daemon add osd ops-ceph-ctrl:/dev/sdc
sudo cephadm shell -- ceph orch daemon add osd ops-ceph-ctrl:/dev/sdd

# ops-ceph-comp1
sudo cephadm shell -- ceph orch daemon add osd ops-ceph-comp1:/dev/sdb
sudo cephadm shell -- ceph orch daemon add osd ops-ceph-comp1:/dev/sdc
sudo cephadm shell -- ceph orch daemon add osd ops-ceph-comp1:/dev/sdd

# ops-ceph-comp2
sudo cephadm shell -- ceph orch daemon add osd ops-ceph-comp2:/dev/sdb
sudo cephadm shell -- ceph orch daemon add osd ops-ceph-comp2:/dev/sdc
sudo cephadm shell -- ceph orch daemon add osd ops-ceph-comp2:/dev/sdd
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; Use &lt;code&gt;hostname:/dev/sdX&lt;/code&gt; format — not &lt;code&gt;user@hostname:/dev/sdX&lt;/code&gt;. The latter causes a &quot;host not found&quot; error. Also, warnings like &lt;code&gt;Not using image... not in the list of non-dangling images&lt;/code&gt; are cosmetic and safe to ignore.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;3.7 Verify Cluster&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo cephadm shell -- ceph -s
sudo cephadm shell -- ceph osd tree
sudo cephadm shell -- ceph df
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Make sure all 9 OSDs (3 nodes × 3 disks) are &lt;code&gt;up&lt;/code&gt; and &lt;code&gt;in&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here&apos;s what the Ceph Block Images dashboard looks like after a successful deployment:&lt;/p&gt;
&lt;p&gt;&amp;lt;img src=&quot;/images/blog/evidence/ceph.png&quot; alt=&quot;Ceph Block Images Dashboard&quot; style=&quot;max-width: 100%; border-radius: 8px; margin: 1rem 0;&quot; /&amp;gt;&lt;/p&gt;
&lt;p&gt;And the Grafana monitoring dashboard showing cluster health, OSD performance, and resource utilization:&lt;/p&gt;
&lt;p&gt;&amp;lt;img src=&quot;/images/blog/evidence/ceph-grafana.png&quot; alt=&quot;Ceph Grafana Monitoring Dashboard&quot; style=&quot;max-width: 100%; border-radius: 8px; margin: 1rem 0;&quot; /&amp;gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. Ceph Pool &amp;amp; Keyring Setup for OpenStack&lt;/h2&gt;
&lt;h3&gt;4.1 Create Pools&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo cephadm shell -- bash -c &quot;
  ceph osd pool create volumes
  ceph osd pool create images
  ceph osd pool create backups
  ceph osd pool create vms

  rbd pool init volumes
  rbd pool init images
  rbd pool init backups
  rbd pool init vms
&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.2 Create Keyrings&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo cephadm shell -- bash -c &quot;
  ceph auth get-or-create client.glance \
    mon &apos;allow r&apos; \
    osd &apos;allow class-read object_prefix rbd_children, allow rwx pool=images&apos; \
    -o /etc/ceph/ceph.client.glance.keyring

  ceph auth get-or-create client.cinder \
    mon &apos;allow r&apos; \
    osd &apos;allow class-read object_prefix rbd_children, allow rwx pool=volumes, allow rwx pool=images&apos; \
    -o /etc/ceph/ceph.client.cinder.keyring

  ceph auth get-or-create client.nova \
    mon &apos;allow r&apos; \
    osd &apos;allow class-read object_prefix rbd_children, allow rwx pool=vms, allow rx pool=images&apos; \
    -o /etc/ceph/ceph.client.nova.keyring

  ceph auth get-or-create client.cinder-backup \
    mon &apos;allow r&apos; \
    osd &apos;allow class-read object_prefix rbd_children, allow rwx pool=backups&apos; \
    -o /etc/ceph/ceph.client.cinder-backup.keyring
&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.3 Export Keyrings to Host&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo cephadm shell -- ceph auth get client.glance | sudo tee /etc/ceph/ceph.client.glance.keyring
sudo cephadm shell -- ceph auth get client.cinder | sudo tee /etc/ceph/ceph.client.cinder.keyring
sudo cephadm shell -- ceph auth get client.nova | sudo tee /etc/ceph/ceph.client.nova.keyring
sudo cephadm shell -- ceph auth get client.cinder-backup | sudo tee /etc/ceph/ceph.client.cinder-backup.keyring

sudo chmod 640 /etc/ceph/ceph.client.*.keyring
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4.4 Generate ceph.conf&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo cephadm shell -- ceph config generate-minimal-conf | sudo tee /etc/ceph/ceph.conf

# Remove leading tabs to prevent kolla-ansible issues
sudo sed -i &apos;s/^\t//&apos; /etc/ceph/ceph.conf

cat /etc/ceph/ceph.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;5. Install Kolla-Ansible&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;Run from &lt;strong&gt;ops-ceph-ctrl&lt;/strong&gt; as user &lt;code&gt;opsce&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;5.1 Create Virtual Environment&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;python3 -m venv ~/kolla-venv
source ~/kolla-venv/bin/activate

# Auto-activate on login
echo &apos;source ~/kolla-venv/bin/activate&apos; &amp;gt;&amp;gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.2 Install Kolla-Ansible&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;pip install -U pip
pip install &apos;ansible-core&amp;gt;=2.16,&amp;lt;2.17&apos;
pip install docker dbus-python

# Install kolla-ansible from master
pip install git+https://opendev.org/openstack/kolla-ansible@master
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.3 Setup Kolla Directories&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /etc/kolla
sudo chown $USER:$USER /etc/kolla

cp -r ~/kolla-venv/share/kolla-ansible/etc_examples/kolla/* /etc/kolla/
cp ~/kolla-venv/share/kolla-ansible/ansible/inventory/multinode ~/multinode
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5.4 Install Ansible Galaxy Dependencies&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kolla-ansible install-deps
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;6. Kolla-Ansible Configuration&lt;/h2&gt;
&lt;h3&gt;6.1 Ansible Config&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir -p /etc/ansible
sudo tee /etc/ansible/ansible.cfg &amp;lt;&amp;lt;EOF
[defaults]
host_key_checking = False
pipelining = True
forks = 100
interpreter_python = /usr/bin/python3
EOF
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.2 Multinode Inventory&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;nano ~/multinode
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Update the top section:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[control]
ops-ceph-ctrl

[network]
ops-ceph-ctrl

[compute]
ops-ceph-comp1
ops-ceph-comp2

[monitoring]
ops-ceph-ctrl

[storage]
ops-ceph-ctrl
ops-ceph-comp1
ops-ceph-comp2

[deployment]
localhost       ansible_connection=local
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;Leave all &lt;code&gt;[xxx:children]&lt;/code&gt; groups as default from the template.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Verify:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ansible -i ~/multinode all -m ping
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.3 Generate Passwords&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kolla-genpwd

# Set admin password (replace with your own secure password)
sed -i &apos;s/^keystone_admin_password:.*/keystone_admin_password: &amp;lt;your-password&amp;gt;/&apos; /etc/kolla/passwords.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.4 Configure globals.yml&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;nano /etc/kolla/globals.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# =====================
# BASE IMAGE
# =====================
kolla_base_distro: &quot;ubuntu&quot;

# =====================
# NETWORKING
# =====================
network_interface: &quot;ens18&quot;
neutron_external_interface: &quot;ens19&quot;
kolla_internal_vip_address: &quot;192.168.205.100&quot;

# =====================
# ENABLE SERVICES
# =====================
enable_openstack_core: &quot;yes&quot;
enable_cinder: &quot;yes&quot;
enable_cinder_backup: &quot;yes&quot;

# =====================
# CEPH INTEGRATION
# =====================
glance_backend_ceph: &quot;yes&quot;
cinder_backend_ceph: &quot;yes&quot;
nova_backend_ceph: &quot;yes&quot;

ceph_glance_user: &quot;glance&quot;
ceph_glance_pool_name: &quot;images&quot;
ceph_cinder_user: &quot;cinder&quot;
ceph_cinder_pool_name: &quot;volumes&quot;
ceph_cinder_backup_user: &quot;cinder-backup&quot;
ceph_cinder_backup_pool_name: &quot;backups&quot;
ceph_nova_user: &quot;nova&quot;
ceph_nova_pool_name: &quot;vms&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;kolla_internal_vip_address&lt;/code&gt; must be an unused IP in the &lt;code&gt;ens18&lt;/code&gt; subnet. This IP will automatically appear on the &lt;code&gt;ens18&lt;/code&gt; interface of ops-ceph-ctrl after deployment as the HAProxy VIP.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;7. External Ceph Integration with Kolla-Ansible&lt;/h2&gt;
&lt;h3&gt;7.1 Create Directories&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p /etc/kolla/config/glance
mkdir -p /etc/kolla/config/nova
mkdir -p /etc/kolla/config/cinder/cinder-volume
mkdir -p /etc/kolla/config/cinder/cinder-backup
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.2 Copy ceph.conf&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cp /etc/ceph/ceph.conf /etc/kolla/config/glance/ceph.conf
cp /etc/ceph/ceph.conf /etc/kolla/config/nova/ceph.conf
cp /etc/ceph/ceph.conf /etc/kolla/config/cinder/ceph.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.3 Copy Keyrings&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Glance
cp /etc/ceph/ceph.client.glance.keyring \
   /etc/kolla/config/glance/ceph.client.glance.keyring

# Nova (needs both nova AND cinder keyrings)
sudo cp /etc/ceph/ceph.client.nova.keyring \
   /etc/kolla/config/nova/ceph.client.nova.keyring
sudo cp /etc/ceph/ceph.client.cinder.keyring \
   /etc/kolla/config/nova/ceph.client.cinder.keyring

# Cinder Volume
sudo cp /etc/ceph/ceph.client.cinder.keyring \
   /etc/kolla/config/cinder/cinder-volume/ceph.client.cinder.keyring

# Cinder Backup
sudo cp /etc/ceph/ceph.client.cinder.keyring \
   /etc/kolla/config/cinder/cinder-backup/ceph.client.cinder.keyring
sudo cp /etc/ceph/ceph.client.cinder-backup.keyring \
   /etc/kolla/config/cinder/cinder-backup/ceph.client.cinder-backup.keyring
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.4 Fix Ownership &amp;amp; Permissions&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo chown -R opsce:opsce /etc/kolla/config
sudo chmod 640 /etc/kolla/config/cinder/cinder-backup/*.keyring
sudo chmod 640 /etc/kolla/config/cinder/cinder-volume/*.keyring
sudo chmod 640 /etc/kolla/config/nova/*.keyring
sudo chmod 640 /etc/kolla/config/glance/*.keyring
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.5 Verify File Structure&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;find /etc/kolla/config -type f -printf &quot;%u %p\n&quot; | sort
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expected output (all owned by &lt;code&gt;opsce&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;opsce /etc/kolla/config/cinder/ceph.conf
opsce /etc/kolla/config/cinder/cinder-backup/ceph.client.cinder-backup.keyring
opsce /etc/kolla/config/cinder/cinder-backup/ceph.client.cinder.keyring
opsce /etc/kolla/config/cinder/cinder-volume/ceph.client.cinder.keyring
opsce /etc/kolla/config/glance/ceph.conf
opsce /etc/kolla/config/glance/ceph.client.glance.keyring
opsce /etc/kolla/config/nova/ceph.conf
opsce /etc/kolla/config/nova/ceph.client.cinder.keyring
opsce /etc/kolla/config/nova/ceph.client.nova.keyring
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;8. Deploy OpenStack&lt;/h2&gt;
&lt;h3&gt;8.1 Bootstrap Servers&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kolla-ansible bootstrap-servers -i ~/multinode
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Heads up:&lt;/strong&gt; It&apos;s common for Ceph MON/MGR services to go &lt;code&gt;inactive dead&lt;/code&gt; after this step, since kolla-ansible reconfigures Docker daemon settings. If this happens, a simple &lt;code&gt;sudo reboot&lt;/code&gt; on affected nodes is the cleanest fix. After reboot, verify Ceph health with &lt;code&gt;sudo ceph -s&lt;/code&gt; before proceeding.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;8.2 Pre-checks&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kolla-ansible prechecks -i ~/multinode
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;8.3 Deploy&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kolla-ansible deploy -i ~/multinode
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;Expect this to take &lt;strong&gt;20-60 minutes&lt;/strong&gt; depending on your hardware and network speed. Occasional single-task failures on compute nodes are typically timing-related and generally resolve themselves — verify with &lt;code&gt;openstack compute service list&lt;/code&gt; after deployment.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;8.4 Post-Deploy&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;kolla-ansible post-deploy -i ~/multinode
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;8.5 Install OpenStack Client &amp;amp; Load Credentials&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;pip install python-openstackclient \
  -c https://releases.openstack.org/constraints/upper/2025.2

source /etc/kolla/admin-openrc.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;8.6 Verify Services&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;openstack service list
openstack compute service list
openstack network agent list
openstack volume service list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All services should be &lt;code&gt;up&lt;/code&gt;. Note that &lt;code&gt;cinder-volume&lt;/code&gt; backend shows &lt;code&gt;rbd-1&lt;/code&gt;, confirming successful Ceph integration.&lt;/p&gt;
&lt;p&gt;Here&apos;s the actual output from a working deployment — all services, network agents, compute services, and volume services are confirmed &lt;code&gt;up&lt;/code&gt;:&lt;/p&gt;
&lt;p&gt;&amp;lt;img src=&quot;/images/blog/evidence/list-service-openstack.png&quot; alt=&quot;OpenStack Service List&quot; style=&quot;max-width: 100%; border-radius: 8px; margin: 1rem 0;&quot; /&amp;gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;9. OpenStack Network Setup&lt;/h2&gt;
&lt;h3&gt;9.1 Create Provider Network (External)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;openstack network create \
  --share \
  --external \
  --provider-physical-network physnet1 \
  --provider-network-type flat \
  public-net

openstack subnet create \
  --network public-net \
  --subnet-range 17.19.21.0/24 \
  --gateway 17.19.21.254 \
  --allocation-pool start=17.19.21.100,end=17.19.21.200 \
  --dns-nameserver 8.8.8.8 \
  public-subnet
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;9.2 Create Private/Tenant Network&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;openstack network create private-net

openstack subnet create \
  --network private-net \
  --subnet-range 10.0.0.0/24 \
  --gateway 10.0.0.1 \
  --dns-nameserver 8.8.8.8 \
  private-subnet
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;9.3 Create Router&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;openstack router create main-router
openstack router set main-router --external-gateway public-net
openstack router add subnet main-router private-subnet

# Verify
openstack router show main-router
openstack port list --router main-router
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once the router is created, you can visualize the network topology in Horizon:&lt;/p&gt;
&lt;p&gt;&amp;lt;img src=&quot;/images/blog/evidence/network-openstack.png&quot; alt=&quot;OpenStack Network Topology&quot; style=&quot;max-width: 100%; border-radius: 8px; margin: 1rem 0;&quot; /&amp;gt;&lt;/p&gt;
&lt;h3&gt;9.4 Create Security Group&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;openstack security group create security-group-net \
  --description &apos;Allow SSH and ICMP&apos;

openstack security group rule create \
  --protocol icmp security-group-net

openstack security group rule create \
  --protocol tcp --ingress --dst-port 22 security-group-net

openstack security group rule list security-group-net
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;9.5 Create Floating IP&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;openstack floating ip create public-net
openstack floating ip list
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Floating IPs are only externally reachable if the provider network interface is bridged to your physical network. In isolated lab environments, you can still access VMs via their private IPs using the Neutron router namespace (see Section 11).&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;10. Create VM Instance&lt;/h2&gt;
&lt;h3&gt;10.1 Upload Image&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;wget http://download.cirros-cloud.net/0.6.2/cirros-0.6.2-x86_64-disk.img

openstack image create \
  --disk-format qcow2 \
  --container-format bare \
  --public \
  --file ./cirros-0.6.2-x86_64-disk.img \
  cirros-0.6.2

openstack image list
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;10.2 Create Keypair&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;openstack keypair create \
  --public-key ~/.ssh/id_ed25519.pub \
  controller-key

openstack keypair list
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;10.3 Create Flavor&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;openstack flavor create \
  --ram 512 \
  --disk 8 \
  --vcpus 1 \
  --public \
  c1-small

openstack flavor list
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;10.4 Create Instance&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# First instance
openstack server create \
  --flavor c1-small \
  --image cirros-0.6.2 \
  --key-name controller-key \
  --security-group security-group-net \
  --network private-net \
  cirros-instance-test

# Second instance (for inter-VM connectivity test)
openstack server create \
  --flavor c1-small \
  --image cirros-0.6.2 \
  --key-name controller-key \
  --security-group security-group-net \
  --network private-net \
  cirros-instance-test-2

# Check status
openstack server list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Wait until status is &lt;code&gt;ACTIVE&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;10.5 Assign Floating IP&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# List available floating IPs
openstack floating ip list

# Assign to each instance
openstack server add floating ip cirros-instance-test &amp;lt;floating-ip-1&amp;gt;
openstack server add floating ip cirros-instance-test-2 &amp;lt;floating-ip-2&amp;gt;

# Verify — instances should have 2 IPs (private + floating)
openstack server list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here&apos;s what it looks like in Horizon — both instances are &lt;code&gt;Active&lt;/code&gt; and &lt;code&gt;Running&lt;/code&gt; with their private and floating IPs assigned:&lt;/p&gt;
&lt;p&gt;&amp;lt;img src=&quot;/images/blog/evidence/openstack.png&quot; alt=&quot;OpenStack Instances in Horizon&quot; style=&quot;max-width: 100%; border-radius: 8px; margin: 1rem 0;&quot; /&amp;gt;&lt;/p&gt;
&lt;p&gt;And the detailed server list from the CLI, confirming Ceph-backed storage and network connectivity:&lt;/p&gt;
&lt;p&gt;&amp;lt;img src=&quot;/images/blog/evidence/server-list.png&quot; alt=&quot;OpenStack Server List and Details&quot; style=&quot;max-width: 100%; border-radius: 8px; margin: 1rem 0;&quot; /&amp;gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;11. Verify VM Connectivity&lt;/h2&gt;
&lt;h3&gt;11.1 Check Router Namespace&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# List all namespaces
sudo ip netns list
# Output: qrouter-xxx (id: N), qdhcp-xxx (id: N)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;11.2 Ping VM from Namespace&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Replace &amp;lt;router-id&amp;gt; with the ID from ip netns list output
sudo ip netns exec qrouter-&amp;lt;router-id&amp;gt; ping -c 4 10.0.0.71
sudo ip netns exec qrouter-&amp;lt;router-id&amp;gt; ping -c 4 10.0.0.104
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;11.3 SSH to VM from Namespace&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# SSH using key (recommended)
sudo ip netns exec qrouter-&amp;lt;router-id&amp;gt; ssh -i ~/.ssh/id_ed25519 cirros@10.0.0.71

# Or SSH with password (cirros password: gocubsgo)
sudo ip netns exec qrouter-&amp;lt;router-id&amp;gt; ssh cirros@10.0.0.71
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;11.4 Test Inter-VM Connectivity&lt;/h3&gt;
&lt;p&gt;From inside cirros-instance-test, ping the other VM:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Enter the first VM
sudo ip netns exec qrouter-&amp;lt;router-id&amp;gt; ssh cirros@10.0.0.71

# From inside the VM, ping the second VM
ping 10.0.0.104
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; You cannot ping VMs directly from the controller host — there&apos;s no route to the tenant or provider networks from the host&apos;s default namespace. Always use &lt;code&gt;sudo ip netns exec qrouter-&amp;lt;id&amp;gt;&lt;/code&gt; to access VM networks from the controller.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;11.5 Verify OVS Bridge&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo docker exec openvswitch_vswitchd ovs-vsctl show
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Verify:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;br-ex&lt;/code&gt; has port &lt;code&gt;ens19&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;br-int&lt;/code&gt; has &lt;code&gt;qr-xxx&lt;/code&gt; (router port) and &lt;code&gt;tap-xxx&lt;/code&gt; (VM port)&lt;/li&gt;
&lt;li&gt;All bridge controllers show &lt;code&gt;is_connected: true&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;Troubleshooting&lt;/h2&gt;
&lt;h3&gt;MON/MGR dies after bootstrap-servers&lt;/h3&gt;
&lt;p&gt;kolla-ansible reconfigures Docker settings which can prevent Ceph containers from restarting. A &lt;code&gt;sudo reboot&lt;/code&gt; is the cleanest fix. Alternatively, manually start the services:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl start ceph-&amp;lt;fsid&amp;gt;@mon.&amp;lt;hostname&amp;gt;.service
sudo systemctl start ceph-&amp;lt;fsid&amp;gt;@mgr.&amp;lt;hostname&amp;gt;.&amp;lt;id&amp;gt;.service
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Error &quot;host not found&quot; when adding OSD&lt;/h3&gt;
&lt;p&gt;Use &lt;code&gt;hostname:/dev/sdX&lt;/code&gt; format — &lt;strong&gt;not&lt;/strong&gt; &lt;code&gt;user@hostname:/dev/sdX&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# CORRECT
sudo cephadm shell -- ceph orch daemon add osd &amp;lt;hostname&amp;gt;:/dev/sdb
# WRONG
sudo cephadm shell -- ceph orch daemon add osd user@&amp;lt;hostname&amp;gt;:/dev/sdb
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Warning &quot;Not using image... non-dangling images&quot;&lt;/h3&gt;
&lt;p&gt;Cosmetic warning, not an error. Safe to ignore.&lt;/p&gt;
&lt;h3&gt;kolla-ansible: command not found&lt;/h3&gt;
&lt;p&gt;Ensure the Python virtual environment is activated:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;source ~/kolla-venv/bin/activate
which kolla-ansible
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Missing sudo password during bootstrap-servers&lt;/h3&gt;
&lt;p&gt;Ensure passwordless sudo is configured for your deploy user:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo &quot;&amp;lt;user&amp;gt; ALL=(ALL) NOPASSWD: ALL&quot; | sudo tee /etc/sudoers.d/&amp;lt;user&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;ceph.conf with leading tabs&lt;/h3&gt;
&lt;p&gt;Kolla-ansible may fail to parse ceph.conf if it contains tabs. Strip them:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo sed -i &apos;s/^\t//&apos; /etc/kolla/config/*/ceph.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Cannot ping VM directly from controller&lt;/h3&gt;
&lt;p&gt;This is expected — the host has no route to tenant/provider networks. Use the Neutron router namespace:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo ip netns list
sudo ip netns exec qrouter-&amp;lt;id&amp;gt; ping -c 4 &amp;lt;vm-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ceph.com/en/tentacle/&quot;&gt;Ceph Tentacle Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.ceph.com/en/tentacle/cephadm/install/&quot;&gt;cephadm Install Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.openstack.org/kolla-ansible/latest/reference/storage/external-ceph-guide.html&quot;&gt;Kolla-Ansible External Ceph&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.openstack.org/kolla-ansible/latest/user/quickstart.html&quot;&gt;Kolla-Ansible Quickstart&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;This guide is based on a real-world deployment. Adjust IPs, hostnames, credentials, and disk paths to match your own environment.&lt;/em&gt;&lt;/p&gt;
</content:encoded></item></channel></rss>