<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Aleksander Chotecki on Medium]]></title>
        <description><![CDATA[Stories by Aleksander Chotecki on Medium]]></description>
        <link>https://medium.com/@aleksander.chotecki?source=rss-8277db1f513f------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*dmbNkD5D-u45r44go_cf0g.png</url>
            <title>Stories by Aleksander Chotecki on Medium</title>
            <link>https://medium.com/@aleksander.chotecki?source=rss-8277db1f513f------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 15 Nov 2026 12:16:19 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@aleksander.chotecki/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Building a Kubernetes Platform from Scratch (Part 5): Replacing OpenVPN with NetBird and Authentik…]]></title>
            <link>https://medium.com/@aleksander.chotecki/building-a-kubernetes-platform-from-scratch-part-5-replacing-openvpn-with-netbird-and-authentik-fa9c36e13e41?source=rss-8277db1f513f------2</link>
            <guid isPermaLink="false">https://medium.com/p/fa9c36e13e41</guid>
            <category><![CDATA[bird-net]]></category>
            <category><![CDATA[wireguard]]></category>
            <category><![CDATA[sso-vpn]]></category>
            <category><![CDATA[authentik]]></category>
            <category><![CDATA[kubernetes]]></category>
            <dc:creator><![CDATA[Aleksander Chotecki]]></dc:creator>
            <pubDate>Mon, 24 Aug 2026 06:31:02 GMT</pubDate>
            <atom:updated>2026-08-24T06:31:02.152Z</atom:updated>
            <content:encoded><![CDATA[<h3>Building a Kubernetes Platform from Scratch (Part 5): Replacing OpenVPN with NetBird and Authentik SSO</h3><h4>Introduction</h4><p>Over the last 3 months, I’ve been building a Proxmox-based lab centered around a highly available RKE2 Kubernetes cluster. The environment hosts most of my infrastructure services, including GitOps tooling (ArgoCD), monitoring, secrets management (Azure Key Vault), and identity services (Authentik + EntraID).</p><p>For remote access, I had been using a traditional OpenVPN deployment with LDAP authentication. The setup was reliable and did exactly what it was supposed to do: provide secure access to the lab from anywhere. However, as the environment continued to grow, managing VPN access separately from the rest of my authentication stack started to feel outdated.</p><p>In a previous article, I deployed Authentik inside my RKE2 cluster and federated it with the Microsoft Entra ID as the upstream identity provider. Since basic OpenID Connect federation in Entra ID doesn’t require premium licensing, I decided to use Entra only for authentication while letting Authentik handle application integrations, group management, and Single Sign-On for self-hosted services.</p><p>This approach allowed me to build a centralized identity platform without paying for Microsoft Entra ID Premium P1 or P2 licenses while still providing a modern SSO experience across my homelab.</p><p>At that point, OpenVPN became the odd one out.</p><p>While all other applications were moving toward OpenID Connect and Single Sign-On, VPN access still relied on a separate LDAP-based authentication flow. I wanted a solution that would allow me to authenticate with the same identity provider used across the rest of the environment while keeping the entire stack self-hosted.</p><p>That’s when I decided to test NetBird.</p><p>NetBird combines WireGuard, identity-aware access control, peer management, and modern authentication mechanisms into a single platform. More importantly, it supports external identity providers, making it possible to integrate directly with Authentik and leverage existing users and groups.</p><p>My goal was simple:</p><ul><li>Replace OpenVPN with NetBird</li><li>Remove LDAP authentication from the VPN workflow (by removing OpenVPN)</li><li>Integrate Netbird VPN access with Authentik</li><li>Use Microsoft Entra ID as the authentication source with MFA</li><li>Enable Single Sign-On across the entire environment</li><li>Synchronize users and groups automatically</li><li>Continue running everything on self-hosted infrastructure</li><li>Make it without EntraID license</li></ul><p>In this article, I’ll walk through the complete migration from OpenVPN to NetBird, deployment on Proxmox, integration with Authentik, and configuration of Single Sign-On backed by Microsoft Entra ID.</p><h4>Architecture Before the Migration</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/750/0*FKNDVtOQDersXsvc.png" /><figcaption>My previous setup with OpenVPN used for the connection</figcaption></figure><p>The following diagram shows the lab environment before introducing NetBird.</p><p>The entire platform runs on a Proxmox hypervisor hosting a highly available RKE2 Kubernetes cluster consisting of three control-plane nodes and four worker nodes. OPNsense is used as the edge firewall and router, providing network connectivity between the internal infrastructure and the Internet.</p><p>Remote administrative access is currently provided through OpenVPN running on OPNsense. Administrators connect to the lab through the VPN and authenticate using LDAP credentials.</p><p>Inside Kubernetes, the cluster hosts the platform services used across the environment, including monitoring, GitOps, identity management, and security tooling.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/972/1*dot2U1y2YNbfzUhe6ZJVoQ.png" /><figcaption>Another diagram presenting my current application</figcaption></figure><h4>Prerequisites</h4><p>Before deploying NetBird, there are a few components that should already be in place. In my case, the lab was already fully operational, and remote administration was performed through OpenVPN with LDAP authentication. Since NetBird would eventually replace OpenVPN, I still needed an existing way to access the environment during the deployment and testing phase. Also I use Ansible for configuring the VMs. All the code for this article is available <a href="https://github.com/heyimjustalex/medium/tree/main/Article_5">here</a>.</p><p>If you want to read previous articles on how to set up this lab go to my profile -&gt; <a href="https://medium.com/@aleksander.chotecki">https://medium.com/@aleksander.chotecki</a>.</p><h4>Identity Platform</h4><p>The most important prerequisite is a working identity provider.</p><p>In my setup, Microsoft Entra ID is used only as the authentication source. Users authenticate against Entra ID, while Authentik acts as the central identity platform responsible for application integrations and Single Sign-On.</p><p>This approach allows me to benefit from Entra ID authentication without requiring Premium P1 or P2 licenses, while still leveraging Authentik for OpenID Connect, group synchronization, and SSO across self-hosted services.</p><h4>Infrastructure Access</h4><p>Since the deployment will be performed inside the existing homelab environment, administrative access is required to several systems.</p><p>You should have:</p><ul><li>Access to the Proxmox cluster</li><li>Access to OPNsense</li><li>SSH access to all virtual machines that will be used for NetBird</li></ul><p>At the time of deployment, I was still using OpenVPN with LDAP authentication to reach the internal network and administer the environment remotely.</p><h4>Public Access and DNS</h4><p>NetBird clients must be able to communicate with the management service over the Internet.</p><p>For that reason, you’ll need:</p><ul><li>A public DNS name for NetBird (I use cloudflare for this)</li><li>Port forwarding configured on OPNsense</li><li>TLS certificates for the exposed services (also cloudflare in my case)</li></ul><p>In my environment, OPNsense is responsible for publishing internal services to the Internet and forwarding the required traffic to the NetBird server.</p><h4>Virtual Machines</h4><p>The lab uses three Ubuntu Server 24.04 LTS virtual machines: one NetBird server hosting the management components and two routing peers used to provide access to internal networks. While one routing peer would be enough for most lab deployments, I chose to deploy two for testing and future scalability.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8l0b7CM0qPoEfBqbonkDXg.png" /><figcaption>Target architecture with Netbird VPN</figcaption></figure><h4>Initial Server Preparation</h4><p>The Ansible code used in this article can be found here: <a href="https://github.com/heyimjustalex/medium/tree/main/Article_5">https://github.com/heyimjustalex/medium/tree/main/Article_5</a>.</p><p>Before running the Ansible automation, I performed a minimal bootstrap configuration on each Ubuntu 24.04 VM. This included verifying SSH connectivity and granting the deployment account passwordless sudo privileges, allowing Ansible to execute administrative tasks without interactive password prompts.</p><pre>echo &quot;cloud ALL=(ALL) NOPASSWD: ALL&quot; | \<br>sudo EDITOR=&#39;tee&#39; visudo -f /etc/sudoers.d/99-cloud-ansible</pre><p>This one-time configuration simplifies infrastructure provisioning and enables fully automated deployment of the NetBird components across all servers.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*v3wSUTcMiv3JJj9-eZJjGQ.jpeg" /><figcaption>Configuring passwordless sudo access for the cloud user on the NetBird server and routing peer virtual machines before starting the automated deployment.</figcaption></figure><h4>Automating the Deployment with Ansible</h4><p>Rather than configuring each server manually, I decided to automate the entire deployment with Ansible. This approach made the installation repeatable, reduced the risk of configuration drift, and allowed me to rebuild the environment quickly whenever required.</p><h4>Inventory and Variables</h4><p>The project is structured around a simple inventory consisting of one NetBird server and two routing peers.</p><pre>all:<br>  children:<br>    netbird_instance:<br>      hosts:<br>        netbird:<br>          ansible_host: 10.0.0.200 <br>        <br>    netbird_agents:<br>      hosts:<br>        netbird-router-01:<br>          ansible_host: 10.0.0.201<br>        netbird-router-02:<br>          ansible_host: 10.0.0.202</pre><p>Global variables define the NetBird version, domain name, installation directories, required packages, firewall settings, and other deployment parameters. Sensitive values such as encryption keys, authentication secrets, and Cloudflare credentials are securely stored in Ansible Vault.</p><h4>Deploying the NetBird VPN Management Platform</h4><p>With the inventory and variables prepared, the next step was deploying the NetBird management platform.</p><p>Rather than relying on installation scripts executed directly on the server, the entire deployment is driven through Ansible. The playbook performs environment validation, installs all required dependencies, configures the operating system, and deploys the complete NetBird stack using Docker Compose.</p><p>One of the first tasks validates that the target operating system is supported and that all required deployment variables are present before any changes are made. This helps catch configuration issues early and prevents partially completed installations.</p><p>The server configuration process includes:</p><ul><li>Installing Docker and Docker Compose</li><li>Creating a dedicated Linux account for NetBird</li><li>Preparing application directories and persistent storage</li><li>Configuring TLS certificate storage</li><li>Enabling IPv4 forwarding</li><li>Creating Cloudflare DNS challenge configuration</li></ul><p>The use of Infrastructure as Code ensures that the entire deployment can be recreated in a consistent manner whenever required.</p><p>To execute the ansible scripts, several sensitive values must be defined in Ansible Vault, including the Cloudflare DNS API token used for automatic certificate issuance, the NetBird authentication secret, and the encryption key used to protect internal NetBird data. The vault file also contains setup keys assigned to individual routing peers, allowing them to register automatically during deployment.</p><p>One important detail is that the routing peer setup keys cannot be generated before NetBird is installed. These keys are created manually through the NetBird administration interface after the management platform becomes operational and must then be added to Ansible Vault before deploying the routing peers.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/313/1*0S9vdhp8XtgueQeoKz2yAA.png" /><figcaption>vault.yml contents</figcaption></figure><p>You can create your own vault by executing:</p><pre>ansible-vault create group_vars/vault.yml</pre><h4>Automatic TLS Certificates with Cloudflare</h4><p>Since NetBird is exposed to the public Internet, TLS certificates are required.</p><p>To avoid manual certificate management, I decided to use Let’s Encrypt with Cloudflare DNS validation. This approach allows certificates to be issued automatically without exposing additional services or opening temporary HTTP validation endpoints.</p><p>The playbook creates a dedicated environment file containing the Cloudflare API token used by Traefik:</p><pre>CF_DNS_API_TOKEN=&lt;secret&gt;</pre><p>This enables completely automated certificate provisioning and renewal while keeping the deployment fully self-hosted.</p><h4>Deploying the Stack</h4><p>After preparing the scripts, Ansible deploys the NetBird components through Docker Compose. This is the script that’s used for the server deployment:</p><pre>- name: Install self-hosted NetBird from static config<br>  hosts: netbird_instance<br>  become: true<br>  gather_facts: true<br>vars_files:<br>    - ../group_vars/all.yml<br>    - ../group_vars/vault.yml<br>  tasks:<br>    ########################################################################<br>    # PRE-FLIGHT<br>    ########################################################################<br>    - name: Validate supported Ubuntu version<br>      ansible.builtin.assert:<br>        that:<br>          - ansible_distribution == &quot;Ubuntu&quot;<br>          - ansible_distribution_major_version | int &gt;= 22<br>        fail_msg: &quot;Ubuntu 22.04+ is required&quot;<br>        success_msg: &quot;Supported Ubuntu version detected&quot;<br>    - name: Validate required variables<br>      ansible.builtin.assert:<br>        that:<br>          - netbird_domain | length &gt; 3<br>          - netbird_admin_email | length &gt; 5<br>          - vault_cloudflare_dns_api_token is defined<br>          - vault_cloudflare_dns_api_token | length &gt; 10<br>        fail_msg: &quot;Required variables are missing&quot;<br>    ########################################################################<br>    # PACKAGES<br>    ########################################################################<br>    - name: Update apt cache<br>      ansible.builtin.apt:<br>        update_cache: true<br>        cache_valid_time: 3600<br>    - name: Install required packages<br>      ansible.builtin.apt:<br>        name: &quot;{{ netbird_required_packages }}&quot;<br>        state: present<br>    - name: Ensure docker service is enabled<br>      ansible.builtin.service:<br>        name: docker<br>        enabled: true<br>        state: started<br>    ########################################################################<br>    # USER<br>    ########################################################################<br>    - name: Ensure NetBird user exists<br>      ansible.builtin.user:<br>        name: &quot;{{ netbird_linux_user }}&quot;<br>        shell: /bin/bash<br>        create_home: true<br>        state: present<br>    - name: Add user to docker group<br>      ansible.builtin.user:<br>        name: &quot;{{ netbird_linux_user }}&quot;<br>        groups: docker<br>        append: true<br>    ########################################################################<br>    # DIRECTORIES<br>    ########################################################################<br>    - name: Create required directories<br>      ansible.builtin.file:<br>        path: &quot;{{ item.path }}&quot;<br>        state: directory<br>        owner: &quot;{{ netbird_linux_user }}&quot;<br>        group: &quot;{{ netbird_linux_user }}&quot;<br>        mode: &quot;{{ item.mode }}&quot;<br>      loop:<br>        - { path: &quot;{{ netbird_install_dir }}&quot;, mode: &quot;0750&quot; }<br>        - { path: &quot;{{ netbird_secrets_dir }}&quot;, mode: &quot;0700&quot; }<br>        - { path: &quot;{{ netbird_letsencrypt_dir }}&quot;, mode: &quot;0750&quot; }<br>    - name: Create acme.json<br>      ansible.builtin.file:<br>        path: &quot;{{ netbird_acme_file }}&quot;<br>        state: touch<br>        owner: &quot;{{ netbird_linux_user }}&quot;<br>        group: &quot;{{ netbird_linux_user }}&quot;<br>        mode: &quot;0600&quot;<br>    ########################################################################<br>    # SYSCTL<br>    ########################################################################<br>    - name: Enable IPv4 forwarding<br>      ansible.builtin.copy:<br>        dest: /etc/sysctl.d/99-netbird.conf<br>        owner: root<br>        group: root<br>        mode: &quot;0644&quot;<br>        content: |<br>          net.ipv4.ip_forward=1<br>      when: netbird_enable_ipv4_forwarding | bool<br>    - name: Reload sysctl<br>      ansible.builtin.command: sysctl --system<br>      changed_when: false<br>    ########################################################################<br>    # CLOUDFLARE ENV<br>    ########################################################################<br>    - name: Create cloudflare env<br>      ansible.builtin.copy:<br>        dest: &quot;{{ netbird_cloudflare_env_file }}&quot;<br>        owner: &quot;{{ netbird_linux_user }}&quot;<br>        group: &quot;{{ netbird_linux_user }}&quot;<br>        mode: &quot;0600&quot;<br>        content: |<br>          CF_DNS_API_TOKEN={{ vault_cloudflare_dns_api_token }}<br>    ########################################################################<br>    # COPY STATIC FILES<br>    ########################################################################<br>    - name: Copy docker-compose.yml<br>      ansible.builtin.copy:<br>        src: ../files/netbird/docker-compose.yml<br>        dest: &quot;{{ netbird_compose_file }}&quot;<br>        owner: &quot;{{ netbird_linux_user }}&quot;<br>        group: &quot;{{ netbird_linux_user }}&quot;<br>        mode: &quot;0644&quot;<br>    - name: Create NetBird config<br>      ansible.builtin.template:<br>        src: ../files/netbird/config.yaml.j2<br>        dest: &quot;{{ netbird_config_file }}&quot;<br>        owner: &quot;{{ netbird_linux_user }}&quot;<br>        group: &quot;{{ netbird_linux_user }}&quot;<br>        mode: &quot;0600&quot;<br>    - name: Create dashboard env<br>      ansible.builtin.template:<br>        src: ../files/netbird/dashboard.env.j2<br>        dest: &quot;{{ netbird_install_dir }}/dashboard.env&quot;<br>        owner: &quot;{{ netbird_linux_user }}&quot;<br>        group: &quot;{{ netbird_linux_user }}&quot;<br>        mode: &quot;0644&quot;<br>    ########################################################################<br>    # PERMISSIONS<br>    ########################################################################<br>    - name: Ensure ownership<br>      ansible.builtin.file:<br>        path: &quot;{{ netbird_install_dir }}&quot;<br>        owner: &quot;{{ netbird_linux_user }}&quot;<br>        group: &quot;{{ netbird_linux_user }}&quot;<br>        recurse: true<br>    - name: Ensure acme.json permissions<br>      ansible.builtin.file:<br>        path: &quot;{{ netbird_acme_file }}&quot;<br>        owner: &quot;{{ netbird_linux_user }}&quot;<br>        group: &quot;{{ netbird_linux_user }}&quot;<br>        mode: &quot;0600&quot;<br>    ########################################################################<br>    # VALIDATE COMPOSE<br>    ########################################################################<br>    - name: Validate compose syntax<br>      ansible.builtin.command:<br>        cmd: docker compose config -q<br>        chdir: &quot;{{ netbird_install_dir }}&quot;<br>      changed_when: false<br>    ########################################################################<br>    # DEPLOY<br>    ########################################################################<br>    - name: Pull images<br>      ansible.builtin.command:<br>        cmd: docker compose pull<br>        chdir: &quot;{{ netbird_install_dir }}&quot;<br>      register: compose_pull<br>      changed_when: false<br>    - name: Deploy stack<br>      ansible.builtin.command:<br>        cmd: docker compose up -d<br>        chdir: &quot;{{ netbird_install_dir }}&quot;<br>      register: compose_up<br>      changed_when: false<br>    ########################################################################<br>    # VALIDATION<br>    ########################################################################<br>    - name: Wait for containers<br>      ansible.builtin.pause:<br>        seconds: 20<br>    - name: Get running containers<br>      ansible.builtin.command:<br>        cmd: docker ps --format &#39;{{ &quot;{{&quot; }}.Names{{ &quot;}}&quot; }}&#39;<br>      register: docker_ps<br>      changed_when: false<br>    - name: Show containers<br>      ansible.builtin.debug:<br>        var: docker_ps.stdout_lines<br>    - name: Validate required containers<br>      ansible.builtin.assert:<br>        that:<br>          - &quot;&#39;netbird-traefik&#39; in docker_ps.stdout&quot;<br>          - &quot;&#39;netbird-dashboard&#39; in docker_ps.stdout&quot;<br>          - &quot;&#39;netbird-server&#39; in docker_ps.stdout&quot;<br>        fail_msg: &quot;Required NetBird containers are not running&quot;<br>        success_msg: &quot;NetBird stack is running&quot;<br>    - name: Validate dashboard through Traefik<br>      ansible.builtin.command:<br>        cmd: &gt;<br>          curl -k -s<br>          -o /tmp/netbird-check.html<br>          -w &#39;%{http_code}&#39;<br>          https://localhost/<br>          -H &#39;Host: {{ netbird_domain }}&#39;<br>      register: dashboard_check<br>      changed_when: false<br>    - name: Assert dashboard returns 200<br>      ansible.builtin.assert:<br>        that:<br>          - dashboard_check.stdout == &quot;200&quot;<br>        fail_msg: &quot;Dashboard validation failed&quot;<br>        success_msg: &quot;Dashboard validation passed&quot;<br>    - name: Show compose status<br>      ansible.builtin.command:<br>        cmd: docker compose ps<br>        chdir: &quot;{{ netbird_install_dir }}&quot;<br>      register: compose_ps<br>      changed_when: false<br>    - name: Display compose status<br>      ansible.builtin.debug:<br>        var: compose_ps.stdout_lines<br>    - name: Show server logs<br>      ansible.builtin.command:<br>        cmd: docker logs netbird-server --tail 50<br>      register: server_logs<br>      failed_when: false<br>      changed_when: false<br>    - name: Recreate Traefik<br>      ansible.builtin.command:<br>        cmd: docker compose up -d --force-recreate traefik<br>        chdir: &quot;{{ netbird_install_dir }}&quot;<br>      changed_when: true<br>    - name: Print server logs<br>      ansible.builtin.debug:<br>        var: server_logs.stdout_lines<br>    - name: Show traefik logs<br>      ansible.builtin.command:<br>        cmd: docker logs netbird-traefik --tail 50<br>      register: traefik_logs<br>      failed_when: false<br>      changed_when: false<br>    - name: Print traefik logs<br>      ansible.builtin.debug:<br>        var: traefik_logs.stdout_lines</pre><h4>Publishing NetBird Through OPNsense</h4><p>Before any clients could connect, the NetBird management platform needed to be reachable from the Internet.</p><p>In my environment, OPNsense acts as the perimeter firewall and is responsible for publishing internal services running on virtual machines and Kubernetes workloads. Since the NetBird management server was deployed on a dedicated Ubuntu virtual machine, I configured the required NAT and firewall rules to expose the service externally.</p><p>NetBird requires both HTTPS communication for the management interface and UDP connectivity for peer-to-peer communication. As a result, two port forwarding rules were configured on OPNsense:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/990/1*awlfs2uEe8Zrpe3LItZhtw.png" /><figcaption>Configuring DNAT on my OPNSense router</figcaption></figure><p><strong>For the first log-in you should add the whitelist only for your IP as Netbird requires installation and setting up the initial password when first launched. Also consider adding some WAF rules in cloudflare WAF configuration.</strong></p><h4>Verifying the NetBird Deployment</h4><p>Once the playbook completed successfully, the NetBird management platform should become available through the public DNS name configured for the deployment.</p><p>At this stage, all core components were running behind Traefik, TLS certificates had been issued automatically through Let’s Encrypt, and the NetBird dashboard could be accessed securely over HTTPS.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/876/1*LBAdVD1AQdbUlwxYLKfOXA.png" /><figcaption>Working netbird containers on my server</figcaption></figure><p>The playbook performs several validation checks after deployment, including container health verification and connectivity testing. This helps ensure the environment is fully operational before moving on to client and routing peer configuration.</p><p>NetBird is the central location for managing users, peers, routes, setup keys, and authentication settings and should be accessible under netbird.&lt;yourdomain&gt;.com</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/441/1*wXW1jhdb2crnTg86a7VNEQ.png" /><figcaption>Netbird log-in prompt</figcaption></figure><h4>Creating Setup Keys for Routing Peers</h4><p>Before deploying the routing peers, NetBird requires a mechanism that allows servers to join the network automatically.</p><p>Rather than performing an interactive login on every machine, NetBird provides setup keys that can be used for unattended enrollment. This makes them ideal for infrastructure components such as routers, servers, and automation-managed systems.</p><p>After logging into the NetBird administration interface, navigate to <strong>Peers</strong> and select <strong>Add Peer</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OEMdEIRbc_3X-SLuzkuNRA.jpeg" /><figcaption>Adding peers via the Netbird UI</figcaption></figure><p>Next, choose <strong>Agent</strong> and generate a new setup key.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/791/1*z3bcdK2LgbSdhaajHspFTg.png" /><figcaption>Generating the key for the agents</figcaption></figure><p>The generated key will later be used by the Ansible playbook to perform automatic registration of the routing peers.</p><p>Since my deployment uses two dedicated routing nodes, I created a separate setup key for each peer and stored them securely in Ansible Vault:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/891/1*D8_vB-PSvu52cee1EISYQg.png" /><figcaption>Entering the generated keys to the Ansible script</figcaption></figure><h4>Deploying Routing Peers</h4><p>With the setup keys generated, the routing peers can now be deployed.</p><p>The routing peer installation is handled by a dedicated Ansible playbook that performs the complete provisioning process automatically. This includes package installation, network configuration, NetBird installation, peer registration, and connectivity validation.</p><pre>- name: Install NetBird routing agents<br>  hosts: netbird_agents<br>  become: true<br>  gather_facts: true<br><br>  vars_files:<br>    - ../group_vars/all.yml<br>    - ../group_vars/vault.yml<br><br>  vars:<br>    netbird_deb_file: &quot;netbird_{{ netbird_agent_version }}_linux_amd64.deb&quot;<br>    netbird_deb_url: &quot;https://github.com/netbirdio/netbird/releases/download/v{{ netbird_agent_version }}/{{ netbird_deb_file }}&quot;<br>    netbird_deb_path: &quot;/tmp/{{ netbird_deb_file }}&quot;<br><br>    netbird_setup_key: &quot;{{ vault_netbird_agent_setup_keys[inventory_hostname] }}&quot;<br><br>  tasks:<br><br>    #####################################################################<br>    # VALIDATION<br>    #####################################################################<br><br>    - name: Validate setup key exists<br>      ansible.builtin.assert:<br>        that:<br>          - inventory_hostname in vault_netbird_agent_setup_keys<br>        fail_msg: &quot;Missing setup key for {{ inventory_hostname }}&quot;<br><br>    #####################################################################<br>    # PACKAGES<br>    #####################################################################<br><br>    - name: Update apt<br>      ansible.builtin.apt:<br>        update_cache: true<br><br>    - name: Install prereqs<br>      ansible.builtin.apt:<br>        state: present<br>        name:<br>          - curl<br>          - wget<br>          - ca-certificates<br>          - jq<br>          - iproute2<br>          - iptables<br>          - nftables<br><br>    #####################################################################<br>    # HOSTNAME<br>    #####################################################################<br><br>    - name: Set hostname<br>      ansible.builtin.hostname:<br>        name: &quot;{{ inventory_hostname }}&quot;<br><br>    #####################################################################<br>    # HOSTS<br>    #####################################################################<br><br>    - name: Configure NetBird hostname locally<br>      ansible.builtin.blockinfile:<br>        path: /etc/hosts<br>        marker: &quot;# {mark} NETBIRD&quot;<br>        block: |<br>          {{ netbird_server_ip }} {{ netbird_domain }}<br><br>    #####################################################################<br>    # ROUTING<br>    #####################################################################<br><br>    - name: Enable IPv4 forwarding<br>      ansible.builtin.copy:<br>        dest: /etc/sysctl.d/99-netbird-router.conf<br>        mode: &quot;0644&quot;<br>        content: |<br>          net.ipv4.ip_forward=1<br>          net.ipv4.conf.all.src_valid_mark=1<br><br>    - name: Reload sysctl<br>      ansible.builtin.command: sysctl --system<br>      changed_when: false<br><br>    #####################################################################<br>    # CLEANUP<br>    #####################################################################<br><br>    - name: Disconnect old peer<br>      ansible.builtin.command: netbird down<br>      failed_when: false<br>      changed_when: false<br>      when: netbird_agent_reset_state | bool<br><br>    - name: Stop netbird<br>      ansible.builtin.service:<br>        name: netbird<br>        state: stopped<br>      failed_when: false<br>      when: netbird_agent_reset_state | bool<br><br>    - name: Remove old state<br>      ansible.builtin.file:<br>        path: &quot;{{ item }}&quot;<br>        state: absent<br>      loop:<br>        - /var/lib/netbird<br>        - /etc/netbird<br>      when: netbird_agent_reset_state | bool<br><br>    #####################################################################<br>    # DOWNLOAD<br>    #####################################################################<br><br>    - name: Download NetBird<br>      ansible.builtin.get_url:<br>        url: &quot;{{ netbird_deb_url }}&quot;<br>        dest: &quot;{{ netbird_deb_path }}&quot;<br>        mode: &quot;0644&quot;<br><br>    #####################################################################<br>    # INSTALL<br>    #####################################################################<br><br>    - name: Install NetBird package<br>      ansible.builtin.command:<br>        cmd: &quot;dpkg -i {{ netbird_deb_path }}&quot;<br>      register: dpkg_install<br>      changed_when: true<br><br>    - name: Fix dependencies<br>      ansible.builtin.apt:<br>        state: fixed<br><br>    - name: Start service<br>      ansible.builtin.service:<br>        name: netbird<br>        enabled: true<br>        state: started<br><br>    #####################################################################<br>    # CONNECT<br>    #####################################################################<br><br>    - name: Connect to NetBird<br>      ansible.builtin.command:<br>        cmd: &gt;<br>          netbird up<br>          --management-url https://{{ netbird_domain }}<br>          --setup-key {{ netbird_setup_key }}<br>      register: netbird_up<br>      changed_when: true<br>      no_log: true<br><br>    #####################################################################<br>    # VALIDATION<br>    #####################################################################<br><br>    - name: Wait for initialization<br>      ansible.builtin.pause:<br>        seconds: 5<br><br>    - name: NetBird status<br>      ansible.builtin.command:<br>        cmd: netbird status<br>      register: nb_status<br>      changed_when: false<br><br>    - name: Show status<br>      ansible.builtin.debug:<br>        var: nb_status.stdout_lines<br><br>    - name: Validate connected<br>      ansible.builtin.assert:<br>        that:<br>          - &quot;&#39;Management: Connected&#39; in nb_status.stdout&quot;<br>          - &quot;&#39;Signal: Connected&#39; in nb_status.stdout&quot;<br><br>    - name: Validate route table<br>      ansible.builtin.command:<br>        cmd: ip route<br>      register: route_table<br>      changed_when: false<br><br>    - name: Show routes<br>      ansible.builtin.debug:<br>        var: route_table.stdout_lines</pre><p>The setup key automatically registers the node with the NetBird management service and associates it with the correct account. Once the automation finishes, both routing peers should appear in the NetBird dashboard and be ready for usage.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*SvJ29vFNblUL3hwqcuKDag.jpeg" /><figcaption>The registered router machines visible in the Peers section</figcaption></figure><h4>Organizing Access with Groups and Network Resources</h4><p>At this stage, the NetBird management platform was operational, the routing peers had been successfully enrolled, and connectivity to the management service had been validated.</p><p>However, before remote users could access any internal resources, I needed to define how access should be organized. Rather than assigning permissions directly to individual devices, NetBird uses groups, resources, and policies to control access to internal networks.</p><p>This model aligns well with the identity-centric approach already used throughout the rest of the platform.</p><p><strong>Creating Access Groups</strong></p><p>The first step was creating groups that would later be used for routing and authorization.</p><p>For this deployment, I created two groups:</p><ul><li><strong>vpn</strong> — users who should be allowed to access internal infrastructure resources through NetBird</li><li><strong>routing-peers</strong> — dedicated routing nodes responsible for advertising internal networks into the overlay network</li></ul><p>Creating dedicated groups makes future access management significantly easier, especially when additional users and networks are introduced.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/636/1*wDV15PV8B3CFriR2M7mrXQ.jpeg" /><figcaption>Creating routing peers group</figcaption></figure><p>After creating the routing-peers group, both routing nodes were assigned to it. This allows NetBird to treat both routing servers as a logical routing entity while maintaining high availability and simplifying future network expansion.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/922/1*a_xH5H8bRWxdqVdn_ItmoQ.jpeg" /><figcaption>Assigning peers to the routing-peers group</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/906/1*haY_IQplpwPv83of5SxS4Q.jpeg" /><figcaption>Assigning peers to the routing-peers group</figcaption></figure><h4>Publishing Internal Networks</h4><p>With the routing peers grouped and operational, the next step was exposing the internal management network to remote users.</p><p>NetBird uses a resource-based model, where networks are represented as resources that can later be secured using access policies.</p><p>To publish the Proxmox and Kubernetes management network, I created a new network resource called proxmox-network.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*obHFPY9m6nt4vQOP0KNYMA.jpeg" /><figcaption>Netbird network settings</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/581/1*p_MnvsRBQdnCnsmo3KKxwA.jpeg" /><figcaption>Adding new network resource</figcaption></figure><p>Within that network, I added a resource representing my internal management subnet 10.0.0.0/24.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/694/1*88oNJ4MFyU1UXl-uAg8ejQ.jpeg" /><figcaption>Configuration of the proxmox-vnet resource.</figcaption></figure><h4>Defining Access Policies</h4><p>One of the areas where NetBird differs significantly from traditional VPN solutions is access control.</p><p>With OpenVPN, authenticated users typically receive unrestricted access to all advertised networks. NetBird, on the other hand, allows access decisions to be based on groups and resources.</p><p>To restrict access only to authorized users, I created a policy allowing members of the <strong>vpn</strong> group to access the <strong>proxmox-vnet</strong> resource.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/788/1*W4WQcSS5S04j-BCePq3XYQ.jpeg" /><figcaption>Access policy definition for the proxmox network resource</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/774/1*ccmWTPP7gPICtpohFwv7Og.jpeg" /><figcaption>Access policy definition for the proxmox network resource</figcaption></figure><p>We also need to add the routing peers we’ve installed to the proxmox-network.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/413/1*ZLbNb1U_T4jpMI5coARmbA.jpeg" /><figcaption>Adding the routing peer</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/590/1*F-oHzYTWj6JIAcIvGBslYA.jpeg" /><figcaption>Adding the routing peer</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tTs_6DYSLNiTLQjYeBBt7w.jpeg" /></figure><p>In the end of this process you should see a network with the routing peers in your configuration.</p><h4>Integrating NetBird with Authentik</h4><p>At this point, the network layer was fully operational.</p><p>The routing peers were advertising the internal management network, access policies were in place, and users assigned to the vpn group could reach resources inside the 10.0.0.0/24 subnet.</p><p>However, one major objective of this migration had not yet been achieved.</p><p>Although OpenVPN had been successfully replaced, user authentication was still managed separately from the identity platform used throughout the rest of the environment.</p><p>My goal was to provide the same authentication experience regardless of whether a user was accessing GitLab, ArgoCD, Grafana, or connecting through the VPN.</p><p>To accomplish this, NetBird needed to be integrated with Authentik using OpenID Connect.</p><h4>Existing Identity Architecture</h4><p>In previous articles, I deployed Authentik inside the Kubernetes cluster and configured Microsoft Entra ID as the upstream identity provider.</p><p>As a result, all self-hosted applications already authenticated against Authentik, while Authentik delegated authentication to Microsoft Entra ID.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*R1VG-GEMnnjitxZG0yDV1g.png" /><figcaption>The current architecture with Authentik SSO</figcaption></figure><h4>Preparing User Groups</h4><p>Before configuring the OpenID Connect integration, I created dedicated VPN groups inside Microsoft Entra ID.</p><p>These groups are used to control which users should be allowed to access internal resources through NetBird.</p><p>For this deployment, two groups were created:</p><ul><li>vpn-admins</li><li>vpn-users</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nw_tznvwI9qJ9FJ0ft2eNg.png" /><figcaption>The groups created with EntraID</figcaption></figure><h4>Creating the NetBird Application in Authentik</h4><p>With the VPN infrastructure fully operational, the next step was integrating NetBird with the existing identity platform.</p><p>Since Authentik was already federated with Microsoft Entra ID and used across several self-hosted applications within the environment, it made sense to reuse the same authentication flow for VPN access.</p><p>NetBird supports OpenID Connect (OIDC), making the integration relatively straightforward.</p><p>The first step was creating a dedicated OAuth2/OpenID application inside Authentik.</p><p>I started by creating a new application called <strong>netbird</strong> and selecting an <strong>OAuth2/OpenID Provider</strong>.</p><h4>Configuring the OAuth2 Provider</h4><p>The provider was configured as a confidential client using the Authorization Code flow. This generates the Client ID and Client Secret that will later be used by NetBird when communicating with Authentik.+1</p><p>One important configuration item is the redirect URI. NetBird expects authentication responses to be returned to: https://netbird.&lt;your-domain&gt;/oauth2/callback</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hcqtGMcIToLxj20q0VU1GA.jpeg" /><figcaption>Authentik application object configuration (OpenID Provider)</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RgSXk_F-JteuVP-7UldAhw.png" /><figcaption>Selecting Oauth/OpenID Provider</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*4BzzZvkpnTM0TSYV5nOG6Q.png" /><figcaption>Configuring the provider for NetBird in Authentik</figcaption></figure><pre>https://netbird.&lt;your-domain&gt;/oauth2/callback</pre><p>This URL must be configured in the Authentik provider settings; otherwise authentication will fail during the OAuth flow.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mtbbJn4FYY0gKtG4KDFN2Q.jpeg" /><figcaption>More provider configurations</figcaption></figure><p>I also configured the subject identifier to use the user’s email address and enabled the inclusion of claims in the ID token. This allows NetBird to consume user and group information directly from the authentication response.</p><h4>Mapping User Groups</h4><p>Authentication alone was not enough.</p><p>One of the main goals of the migration was ensuring that NetBird could automatically consume user group information and use it for authorization decisions.</p><p>To achieve this, I created a custom Scope Mapping in Authentik that exposes group membership through the groups claim. Instead of publishing every group available inside Authentik, the mapping filters and returns only groups related to NetBird access.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OlqN9hhm_69I-TbaylZLng.jpeg" /><figcaption>Creating new property mapping to filter out groups that are not relevant</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*rDwo-EgyhajDaltghZwqXw.jpeg" /><figcaption>Choosing the scope mapping</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*FM6SGmidqiAUxJ_S3GmEdA.jpeg" /><figcaption>Filtering out the groups that are not relevant to the netbird provider</figcaption></figure><p>This approach keeps identity information clean while allowing NetBird to make authorization decisions based on existing Authentik groups. The OpenID provider must also be updated to replace the default profile scope with the custom scope mapping created earlier.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zczUJtgUGddULh0TJEPlvw.jpeg" /><figcaption>Swapping the profile scope with our scope mapping</figcaption></figure><h4>Connecting NetBird to Authentik</h4><p>With the application and OAuth provider configured, the next step was connecting NetBird to Authentik.</p><p>Inside the NetBird administration interface, I navigated to <strong>Settings → Identity Providers</strong> and created a new OIDC identity provider.</p><p>The configuration required:</p><ul><li>OpenID Discovery URL</li><li>Client ID</li><li>Client Secret</li><li>Issuer URL</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lgml6aXwrDs4533sN6cLxw.jpeg" /><figcaption>Opening the Identity Provider Netbird section</figcaption></figure><p>All of these values were obtained directly from the provider configuration generated earlier in Authentik.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*vA58PNkWMwGPiP2s2KYKeQ.png" /><figcaption>The details needed for OIDC configuration in Netbird</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/446/1*eDD_8Ji6wcgEuYE22jpR7g.png" /><figcaption>Entering the details to the Netbird IDP configuration</figcaption></figure><p>After saving the configuration, Authentik should become available as a login method within NetBird.</p><h4>Enabling Group Synchronization</h4><p>One feature I particularly wanted was automatic synchronization of user groups.</p><p>Instead of manually managing permissions inside NetBird, I wanted access control to remain identity-driven and managed directly from Microsoft Entra ID and Authentik.</p><p>NetBird supports this through JWT group synchronization.</p><p>After enabling group propagation and JWT group synchronization, NetBird automatically extracts group membership information from the groups claim included in the ID token.</p><p>As a result:</p><ul><li>Groups created in Microsoft Entra ID</li><li>Synchronized to Authentik</li><li>Included in OIDC tokens</li><li>Imported automatically into NetBird</li></ul><p>Users now inherit access based entirely on their assigned identity groups rather than manually maintained VPN permissions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*K77PDKqj9l-1JmNM9jiflg.jpeg" /><figcaption>Enabling JWT-based group synchronization in NetBird.</figcaption></figure><h4>Testing the Authentication Flow</h4><p>Once the integration was complete, I tested the end-to-end login process.</p><p>When a user selects <strong>Continue with Authentik</strong>, NetBird redirects authentication requests to Authentik, which in turn forwards the user to Microsoft Entra ID for authentication.</p><p>The authentication process now follows this flow:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/480/1*lBtlVZ-HdWTKTpVgJ5_qJA.jpeg" /><figcaption>Netbird login screen.</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/592/1*QANWtEOrCsO0poNwrQNtMA.jpeg" /><figcaption>We can click ‘Log-in with Microsoft Entra ID’, because we have a working federation with MS Entra ID</figcaption></figure><p>From the user’s perspective, VPN authentication now behaves exactly like every other application in the platform.</p><p>Single Sign-On is preserved, Multi-Factor Authentication is handled by Entra ID, and user access is controlled through centrally managed groups.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/446/1*JoHDGdHaDh0YjSJtMoBCIw.png" /><figcaption>We can utilize the Entra ID MFA enforcement for our VPN log-in now.</figcaption></figure><p>After logging-in the groups from Entra should be present in Netbird</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pJaEhAgEq8pdK4DsH43Nyg.jpeg" /><figcaption>Groups from EntraID have been provisioned to Netbird</figcaption></figure><p>The user should also be present in the Team -&gt; Users tab in Netbird. If you want to have admin rights the vpn-admins group will not work, as you cannot assign the ‘Admin’ role to a group yet in Netbird. What you can do, you can assign it to the user.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/976/1*78xL1DBXDG9XMuXFVie7xA.png" /><figcaption>Assigning Admin user for my EntraID account</figcaption></figure><h4>Final Network Adjustment</h4><p>Earlier in the deployment, I created a local <strong>vpn</strong> group inside NetBird and used it in the access policy for the proxmox-vnet resource. However, the groups synchronized from Microsoft Entra ID are named <strong>vpn-users</strong> and <strong>vpn-admins</strong>.</p><p>Since group names must match the identities that NetBird receives from Authentik, the policy needs to be updated to reference the synchronized <strong>vpn-users</strong> group instead of the manually created <strong>vpn</strong> group.</p><p>To do this, edit the access policy associated with the proxmox-vnet resource and replace the original source group with <strong>vpn-users</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/771/1*rzG4nVUiKv6EJiHd2Axifg.png" /><figcaption>Updating the access policy to use the synchronized vpn-users group.</figcaption></figure><p>After saving the changes, users who are members of the <strong>vpn-users</strong> group in Microsoft Entra ID will automatically receive access to the published network once group synchronization between Entra ID, Authentik, and NetBird has been configured. This removes the need to manage VPN permissions manually inside NetBird and allows access control to be driven entirely through the centralized identity platform.</p><h4>Installing the NetBird Desktop Client</h4><p>With the network resources, routing peers, and access policies configured, the final step is connecting a user workstation to the NetBird network.</p><p>NetBird provides native clients for Windows, macOS, Linux, Android, and iOS. For this article, I used the Windows desktop client.</p><p>The latest client can be downloaded directly from the NetBird download page:</p><pre>https://app.netbird.io/install</pre><p>After installing the application, launch the NetBird client and select <strong>edit profile</strong></p><p>Next, enter the URL of your self-hosted NetBird management service:</p><pre>https://netbird.&lt;your-domain&gt;.com</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/379/1*o-ijdaWNSgKqMEs9KlT1kg.png" /><figcaption>Choosing self-hosted instance for my profile</figcaption></figure><p>After clicking Connect, NetBird redirects the browser to the configured identity provider. Since NetBird is integrated with Authentik, authentication is forwarded to Authentik which then redirects the user to Microsoft Entra ID.</p><p>After a successful authentication, the device is automatically registered in NetBird and assigned to the appropriate groups based on the information received from Authentik.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/877/1*5kzdpqjYnXHrouo1UyyB8A.jpeg" /><figcaption>My laptop has connected to the Netbird network.</figcaption></figure><h4>Verifying Connectivity</h4><p>Once the client connects successfully, NetBird automatically installs the routes associated with the resources and access policies configured earlier.</p><p>On Windows, the route table can be verified using:</p><pre>route print</pre><p>You should see the internal management subnet advertised through the NetBird interface:</p><pre>10.0.0.0/24</pre><h4>Summary</h4><p>At this point, infrastructure resources such as Proxmox, OPNsense, Kubernetes nodes, and other management services should be reachable through the VPN connection.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ClV5m1RcaWItO_eJFp6ldA.png" /><figcaption>The new architecture that has been deployed</figcaption></figure><p>Unlike the previous OpenVPN deployment, no LDAP authentication is involved in this process. Authentication is performed through Microsoft Entra ID, authorization is managed through Authentik and NetBird groups, and connectivity is provided through the WireGuard-based NetBird overlay network. This results in a significantly simpler user experience while maintaining centralized identity management across the entire platform.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=fa9c36e13e41" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Kubernetes Platform from Scratch (Part 4): Centralized Authentication with Authentik…]]></title>
            <link>https://medium.com/@aleksander.chotecki/building-a-kubernetes-platform-from-scratch-part-4-centralized-authentication-with-authentik-21a732676809?source=rss-8277db1f513f------2</link>
            <guid isPermaLink="false">https://medium.com/p/21a732676809</guid>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[rancher]]></category>
            <category><![CDATA[sso]]></category>
            <category><![CDATA[gitlab]]></category>
            <category><![CDATA[authentik]]></category>
            <dc:creator><![CDATA[Aleksander Chotecki]]></dc:creator>
            <pubDate>Mon, 17 Aug 2026 07:11:01 GMT</pubDate>
            <atom:updated>2026-08-17T07:11:01.718Z</atom:updated>
            <content:encoded><![CDATA[<h3>Building a Kubernetes Platform from Scratch (Part 4): Centralized Authentication with Authentik, Argo CD, Rancher, and GitLab</h3><p>In the previous article, I deployed Authentik as part of the platform services layer and integrated it into the GitOps-managed Kubernetes environment. While the identity platform itself was already running, it was not yet connected to any external identity source and none of the platform services were using it for authentication.</p><p>At this stage, users still authenticated directly against individual applications, resulting in separate login flows, isolated user management, and no consistent Single Sign-On experience across the environment.</p><p>To solve this, I decided to make Authentik the central identity provider for the entire platform and use Microsoft Entra ID as the upstream authentication source. This approach provides a modern authentication experience, Multi-Factor Authentication support, centralized group management, and Single Sign-On capabilities without requiring Microsoft Entra ID Premium P1 or P2 licenses.</p><p>The goal of this article is to build a complete identity architecture that all future platform services can rely on.</p><p>By the end of this article:</p><ul><li>Microsoft Entra ID will become the authoritative identity source</li><li>Authentik will act as the central Identity Provider</li><li>Argo CD will authenticate through Authentik using OpenID Connect</li><li>Rancher will authenticate through Authentik using OpenID Connect</li><li>GitLab will be deployed automatically using Ansible</li><li>GitLab will be integrated with Authentik through OpenID Connect (authentication only, as community edition does not support IDP-level authorization)</li><li>User access will be managed through centrally managed groups</li><li>Multi-Factor Authentication will be enforced through Microsoft Entra ID</li><li>A single authentication workflow will be used across the platform</li></ul><p>The code used in this article can be found <a href="https://github.com/heyimjustalex/medium/tree/main/Article_4">here</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iwFxKzDIZaHMJt-JhLZJPA.png" /><figcaption>Target architecture: Authentik as a central source of identity (federated with Microsoft Entra ID)</figcaption></figure><h3>Federating Authentik with Microsoft Entra ID</h3><p>In the previous article, I deployed Authentik as part of the platform services layer and integrated it into the GitOps-managed Kubernetes environment. While the identity provider itself was operational, it still relied on locally managed users and groups.</p><p>Before integrating Authentik with applications such as Argo CD, Rancher, and GitLab, I wanted to establish a centralized identity source that could be reused across the entire platform.</p><p>Rather than managing users separately inside every application, I decided to use Microsoft Entra ID as the authentication source and Authentik as the central Identity Provider.</p><p>This approach offers several advantages:</p><ul><li>Centralized user lifecycle management</li><li>Single Sign-On across all applications</li><li>Multi-Factor Authentication enforced by Entra ID</li><li>Consistent group management</li><li>Reduced operational overhead</li><li>No requirement for Microsoft Entra ID Premium P1 or P2 licenses</li></ul><p>The step-by-step guide for this can be found in <a href="https://docs.goauthentik.io/users-sources/sources/social-logins/entra-id/oauth/">Authentik docs.</a></p><h4>Creating the App Registration</h4><p>The first step is creating a new App Registration in Microsoft Entra ID that will represent Authentik.</p><p>Inside the Microsoft Entra Admin Center, navigate to Microsoft Entra ID → App registrations → New registration</p><p>The application is configured as a standard web application and will be used by Authentik to redirect users to Microsoft Entra ID during the authentication process. Also, the Authentik callback URL must be configured.</p><p>This allows Microsoft Entra ID to return authentication responses back to Authentik after a successful login.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/804/1*17KZcFov2GWgJCBDjtGW6g.png" /><figcaption>Creating App registration for Authentik integration</figcaption></figure><h4>Creating a Client Secret</h4><p>The next step is generating a client secret.</p><p>Authentik uses the Client ID and Client Secret to authenticate against Microsoft Entra ID during the federation process.</p><p>Certificates &amp; Secrets → New Client Secret</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/276/1*zhOH0Y18kZ6gIZ0_bq4g0w.png" /><figcaption>Newly-created Authentik Secret</figcaption></figure><h4>Configuring Microsoft Graph Permissions</h4><p>Before Authentik can retrieve user information from Microsoft Entra ID, the application registration requires access to Microsoft Graph.</p><p>In the App Registration, navigate to:</p><p>API Permissions</p><p>→ Add a Permission</p><p>→ Microsoft Graph</p><p>→ Delegated Permissions</p><p>and configure all the permissions listed in the docs <a href="https://docs.goauthentik.io/users-sources/sources/social-logins/entra-id/oauth/">here</a>.</p><h4>Configuring Microsoft Entra ID as an Authentik Source</h4><p>With the App Registration complete, I moved to the Authentik administration interface. I created a new source and provided:</p><ul><li>Tenant ID</li><li>Application (Client) ID</li><li>Client Secret</li></ul><p>obtained from the App Registration created earlier.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fVeB8zpIkVxi39szlnud9Q.png" /><figcaption>My Entra-ID federation configuration 1</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OAqzhkzI_x3PVOa5sPhpaQ.png" /><figcaption>My Entra-ID federation configuration 2</figcaption></figure><p>Authentik requires the Application (Client) ID, Directory (Tenant) ID, and a Client Secret generated from the App Registration. The Service Principal Object ID created under Enterprise Applications is not used for the federation configuration.</p><h4>Testing the Federation</h4><p>Once the Microsoft Entra ID source has been configured, Authentik will automatically expose a new login option on the authentication page.</p><p>At this stage, I recommend testing the integration before proceeding with any application-specific configuration. This helps verify that the App Registration, redirect URIs, permissions, and credentials were configured correctly.</p><p>On the Authentik login screen, select <strong>Microsoft Entra ID</strong> as the authentication source.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/560/1*mtEZKWxlupb3HYSQnvMYSQ.png" /><figcaption>Log-in screen presents new option — Log-in with Entra ID</figcaption></figure><h4>Designing the Authorization Model</h4><p>Before integrating applications, I wanted to establish a consistent group structure that could be reused across the entire platform.</p><p>Rather than creating permissions directly inside individual applications, I decided to manage authorization through Microsoft Entra ID groups and synchronize them through Authentik.</p><p>For this deployment, I created the following groups:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/311/1*3jvDQMusKeJRlfCfOlWIQg.png" /></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/310/1*Zbz9aJbLKJqFkfu9WnQkqQ.png" /></figure><p>These groups will later be synchronized into Authentik and exposed to downstream applications through OIDC claims. This allows permissions to be managed from a single location while maintaining a consistent authorization model across Argo CD, Rancher and future integrations.</p><h4>Integrating Argo CD with Authentik</h4><p>With Microsoft Entra ID successfully federated with Authentik, the next step was integrating platform services into the new authentication model.</p><p>I decided to start with Argo CD because it serves as the central GitOps engine for the entire platform. Since all deployments are managed through Argo CD, it made sense to eliminate local accounts and move authentication to Authentik as early as possible.</p><p>Inside the Authentik administration interface, navigate to Applications → Create and create a new application called argocd-dex</p><p>I chose this name because Argo CD uses Dex as its authentication component and all OpenID Connect communication is handled through Dex.</p><h4>Configuring the OpenID Provider</h4><p>Next, create an <strong>OAuth2/OpenID Provider</strong> and associate it with the argocd-dex application.</p><p>The provider is configured as a confidential OpenID Connect client and will be responsible for handling authentication requests coming from Argo CD.</p><p>One of the most important settings is the redirect URI. Since Argo CD uses Dex, the callback URL must point to the Dex endpoint:</p><pre>https://argocd.&lt;your-domain&gt;/api/dex/callback</pre><h4>Reusing Existing Client Credentials</h4><p>Since Argo CD was already deployed and managed through GitOps, I had previously generated the OpenID Connect credentials and added them to my automation code.</p><p>Rather than creating a new secret, I reused the existing client credentials already managed through Ansible. The client secret is stored securely in my vault.yml file and injected into Argo CD during deployment.</p><pre>vault_argocd_oidc_client_secret: &lt;secret&gt;</pre><h4>Configuring a Custom Property Mapping</h4><p>By default, Authentik uses the standard profile scope to expose user attributes to applications. While this works well for basic authentication, it would also expose every synchronized group to Argo CD.</p><p>Since I only wanted Argo CD to receive groups related to Argo CD authorization, I created a dedicated Property Mapping and replaced the default profile scope with my custom implementation.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qWdR47ULcYAZ58y3a6GCeQ.png" /><figcaption>Property mapping to provision only argocd groups to argocd</figcaption></figure><p>The expression that has been used for property mapping:</p><pre>groups = [<br>    g.name<br>    for g in request.user.groups.all()<br>    if &quot;argo&quot; in g.name.lower()<br>    <br>]<br><br>return {<br>    &quot;name&quot;: request.user.name,<br>    &quot;given_name&quot;: request.user.name,<br>    &quot;preferred_username&quot;: request.user.username,<br>    &quot;nickname&quot;: request.user.username,<br>    &quot;groups&quot;: groups,<br>}</pre><p>This ensures that only Argo CD related groups are included in the generated token.</p><h4>Replacing the Default Profile Scope</h4><p>Once the Property Mapping was created, I updated the argocd-dex provider configuration and replaced the default profile scope with the newly created custom mapping.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gd-vCL4rtaLamYTeRgUeTA.png" /><figcaption>Replacing default profile with property mapping</figcaption></figure><p>After saving changes the integration with argocd should work. If it does not, make sure that you execute the OIDC configuration script from the second article of this series that can be found <a href="https://github.com/heyimjustalex/medium/blob/main/Article_2/ansible_setup_script/playbooks/16-upgrade-argo-to-oidc.yaml">here</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/669/1*SHgoMxmDaLEKUat-5sRwjw.png" /><figcaption>ArgoCD log-in screen</figcaption></figure><h4>Integrating Rancher with Authentik</h4><p>With Argo CD successfully integrated with Authentik, the next step was extending the same authentication model to Rancher.</p><p>Since Rancher acts as the central management platform for my Kubernetes environment, it was another critical service that should not rely on local users or independently managed credentials.</p><h4>Creating the Rancher Application</h4><p>Inside Authentik, I created another application under:</p><pre>Applications<br>  → Applications<br>  → Create</pre><p>This time the application was named rancher and was associated with a dedicated OAuth2/OpenID Provider.</p><h4>Configuring the OpenID Provider</h4><p>Similar to the Argo CD integration, Rancher requires an OpenID Connect provider and a redirect URI.</p><p>The redirect URI configured for Rancher is:</p><pre>https://rancher.&lt;your-domain&gt;/verify-auth</pre><p>After creating the provider, Authentik generated:</p><ul><li>Client ID</li><li>Client Secret</li><li>Issuer URL</li></ul><p>which are entered into Rancher’s authentication configuration.</p><h4>Configuring Rancher Property Mapping</h4><p>Instead of exposing all synchronized groups to Rancher, I followed the same approach used for Argo CD and created a dedicated Property Mapping.</p><h4>Configuring Rancher Authentication</h4><p>Inside Rancher, navigate to:</p><pre>Users &amp; Authentication<br>  → Auth Provider<br>  → OpenID Connect</pre><p>and enter the details generated earlier by Authentik:</p><ul><li>Client ID</li><li>Client Secret</li><li>Issuer URL</li><li>Auth Endpoint</li><li>Token Endpoint</li><li>UserInfo Endpoint</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*zBeomIb4UeB4Cc-HAzC0LQ.png" /><figcaption>Rancher configuration</figcaption></figure><p>After saving the configuration, Rancher immediately exposes Authentik as the authentication source.</p><p>These groups are synchronized into Authentik and included in the Rancher OIDC token through the custom Property Mapping.</p><p>Inside Rancher, group memberships can then be mapped to appropriate roles such as:</p><pre>rancher-admins → Administrator<br>rancher-users → Standard User</pre><h4>Configuring Project and Namespace Access</h4><p>Authentication is only one part of the integration. Once users can successfully authenticate through Authentik, Rancher’s built-in Role-Based Access Control (RBAC) can be used to restrict access to specific clusters, projects, and namespaces.</p><p>Rather than granting administrator access to every authenticated user, I created dedicated groups in Microsoft Entra ID.</p><p>These groups are synchronized into Authentik and passed to Rancher through the OpenID Connect token. Users can then be assigned to specific Rancher roles based on their group membership.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*q4u7HJvm4MdDX_DBEEar6g.jpeg" /><figcaption>Groups provisioned to my rancher instance</figcaption></figure><h4>Project and Namespace Scoping</h4><p>One feature I particularly like about Rancher is the ability to grant permissions at the Project level.</p><p>Instead of giving users unrestricted access to the entire Kubernetes cluster, access can be limited to specific Projects and the Namespaces contained within them.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*w4MbO68ZvutyNt61wme-jw.png" /><figcaption>Scoping the project access to the group</figcaption></figure><h4>Secure kubeconfig Generation</h4><p>An additional benefit of project-scoped access is that Rancher automatically limits the permissions available through generated kubeconfig files.</p><p>When a user downloads a kubeconfig from Rancher, the credentials embedded in that configuration inherit the permissions associated with the user’s assigned role.</p><p>As a result:</p><ul><li>Project members can only access resources inside authorized namespaces</li><li>Cluster-wide administrative privileges are restricted to administrators</li><li>Access control remains consistent between the Rancher UI and Kubernetes API</li></ul><p>This significantly reduces the risk of granting excessive cluster permissions to application teams while maintaining a self-service experience.</p><h4>Troubleshooting Certificate Validation</h4><p>During the initial OpenID Connect configuration, I encountered certificate validation issues between Rancher and Authentik.</p><p>Since both applications are exposed through Cloudflare and use certificates issued through cert-manager and served by the Traefik ingress controller, certificate trust became one of the first areas to validate.</p><p>Rancher stores custom certificate authorities inside the cacerts setting located in the cattle-system namespace.</p><p>To verify whether Rancher is using a custom CA bundle, I checked the current configuration:</p><pre>kubectl -n cattle-system get settings.management.cattle.io cacerts -o yaml</pre><p>In my case, the setting contained a custom CA bundle that was no longer required. Because both Rancher and Authentik were already using publicly trusted certificates issued by Let’s Encrypt, Rancher could rely on the default system trust store instead of a custom CA chain.</p><p>To resolve the issue, I removed the custom certificate configuration by clearing the cacerts value.</p><p>After the change, Rancher automatically reverted to using the default trusted Certificate Authorities available inside the container image and was able to validate the Authentik endpoint correctly.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/323/1*yl_pJPkA8Gv08uB8gARxzA.png" /><figcaption>Working OIDC for Rancher</figcaption></figure><h4>Deploying GitLab with Ansible</h4><p>With Argo CD and Rancher now integrated into the centralized identity platform, the final application remaining was GitLab.</p><p>Unlike the previous integrations, this was not only an authentication project.</p><p>At the time, I was already running GitLab inside the lab environment, but the deployment had accumulated technical debt over time. The instance was running an older version, database and application components were deployed together, and upgrading the platform was becoming increasingly difficult.</p><p>Rather than performing an in-place upgrade, I decided to rebuild the platform from scratch.</p><p>The migration had three primary goals:</p><ul><li>Upgrade GitLab to the latest supported version</li><li>Separate PostgreSQL from the GitLab application server</li><li>Fully automate the deployment using Ansible</li></ul><p>This would provide a cleaner architecture and make future upgrades significantly easier to perform.</p><h4>Target GitLab Architecture</h4><p>Instead of running all components on a single virtual machine, the new deployment separates the database and application layer.</p><p>This separation follows a more production-oriented design while making database maintenance and future migrations considerably simpler.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3qilf-apFZyi81Li2dVUbQ.png" /><figcaption>GitLab Architecture design</figcaption></figure><p>Since the rest of the platform is already managed through Ansible, I wanted the GitLab deployment to follow the same approach.</p><p>The migration workflow is fully automated with Ansible and includes:</p><ol><li>Linux server preparation</li><li>PostgreSQL installation and configuration</li><li>PostgreSQL validation</li><li>GitLab installation</li><li>Container Registry configuration</li><li>OIDC integration with Authentik</li></ol><p>Rather than executing commands manually, each phase is validated before the next one begins. This provides repeatable deployments and immediate feedback whenever something fails.</p><p>The code for this part can be found here:</p><p><a href="https://github.com/heyimjustalex/medium/tree/main/Article_4/gitlab_ansible/playbooks">https://github.com/heyimjustalex/medium/tree/main/Article_4/gitlab_ansible/playbooks</a></p><h4>Deployment Workflow</h4><p>The entire deployment is fully automated with Ansible and executed in several stages. Each playbook is responsible for a specific part of the migration process and includes validation checks before moving to the next phase.</p><h4>1. Bootstrap SSH Access</h4><p>Creates the Ansible user on all target hosts and installs the SSH public key used by the automation platform.</p><pre>ansible-playbook playbooks/01-upload-ssh-keys.yml</pre><h4>2. Harden SSH Configuration</h4><p>Applies basic SSH hardening by disabling root logins, reducing the attack surface, and validating the final SSH configuration before reloading the service.</p><pre>ansible-playbook playbooks/02-harden-ssh-config.yml</pre><h4>3. Prepare Linux Hosts</h4><p>Performs baseline operating system configuration on both GitLab and PostgreSQL servers. This includes package installation, host configuration, DNS entries, swap removal, and system tuning.</p><pre>ansible-playbook playbooks/03-prepare-linux.yml</pre><h4>4. Deploy PostgreSQL</h4><p>Installs PostgreSQL 17 on the dedicated database server, creates the GitLab database, provisions the GitLab service account, configures network access, and validates the deployment.</p><pre>ansible-playbook playbooks/04-install-postgres.yml --ask-vault-pass</pre><h4>5. Validate Database Connectivity</h4><p>Before deploying GitLab, this playbook verifies connectivity between the GitLab server and PostgreSQL. Additional checks validate DNS resolution, database authentication, UTF-8 encoding, and PostgreSQL version compatibility.</p><pre>ansible-playbook playbooks/05-test-postgres.yml --ask-vault-pass</pre><h4>6. Deploy GitLab CE</h4><p>Installs GitLab CE, configures the external URL, connects GitLab to the dedicated PostgreSQL instance, performs the initial reconfiguration, and validates that the deployment completed successfully. The playbook also verifies that the selected GitLab and PostgreSQL versions are compatible.</p><pre>ansible-playbook playbooks/06-install-gitlab.yml --ask-vault-pass</pre><h4>7. Configure GitLab OpenID Connect</h4><p>Integrates GitLab with Authentik by configuring OpenID Connect authentication inside GitLab. The playbook updates the GitLab configuration and validates that the OIDC provider is available after reconfiguration.</p><pre>ansible-playbook playbooks/07-oidc-conf.yml --ask-vault-pass</pre><h4>8. Enable GitLab Container Registry</h4><p>Enables the integrated container registry and publishes it through a dedicated endpoint. This registry is later used by Kubernetes workloads and CI/CD pipelines to store container images.</p><pre>ansible-playbook playbooks/08-configure-container-registry.yml</pre><h4>Preparing the Authentik Application for GitLab</h4><p>Before executing the OpenID Connect configuration playbook, an application object must first be created in Authentik.</p><p>I created a dedicated application called:</p><pre>gitlab</pre><p>together with an OAuth2/OpenID Provider configured for GitLab.</p><p>The redirect URI used by GitLab is:</p><pre>https://gitlab.&lt;your-domain&gt;/users/auth/openid_connect/callback</pre><p>After creating the provider, Authentik generates a Client ID and Client Secret which are later consumed by the Ansible automation. The credentials are stored securely inside the encrypted vault.yml file rather than being committed directly into the repository.</p><pre>vault_gitlab_oidc_client_id: &lt;secret&gt;<br>vault_gitlab_oidc_client_secret: &lt;secret&gt;</pre><p>This allows the full GitLab OIDC configuration to remain reproducible while keeping sensitive authentication data protected through Ansible Vault.</p><p>At this stage, the platform is ready for the final step: integrating GitLab with Authentik and validating the complete authentication flow through Microsoft Entra ID.</p><h4>Summary</h4><p>At this stage, the platform finally has a centralized identity layer.</p><p>Throughout this article, I:</p><ul><li>Federated Authentik with Microsoft Entra ID</li><li>Configured Microsoft Graph permissions and identity synchronization</li><li>Established a consistent authorization model based on Entra ID groups</li><li>Integrated Argo CD with Authentik using OpenID Connect</li><li>Implemented custom Property Mappings to expose only application-specific groups</li><li>Integrated Rancher with Authentik and configured role-based access control</li><li>Leveraged Rancher Projects and Namespaces to scope user access and generated kubeconfigs</li><li>Deployed a new GitLab environment using Ansible</li><li>Migrated GitLab to a dedicated architecture with separate PostgreSQL and application servers <strong>(remember to migrate the ArgoCD repository and change ArgoCD settings if you migrate GitLab instance like me. Also use a GitLab service account for this.)</strong></li><li>Enabled GitLab Container Registry</li><li>Integrated GitLab with Authentik using OpenID Connect</li></ul><p>Users authenticate using Microsoft Entra ID, Multi-Factor Authentication is enforced centrally, and application access is managed through groups synchronized into Authentik.</p><p>Perhaps more importantly, onboarding and offboarding users is now significantly simpler. Instead of managing accounts and permissions separately in every application, access can be controlled from a single location using Entra ID groups and Authentik.</p><p>However, there was still one component operating outside of this architecture.</p><p>Remote access to the platform was still provided through OpenVPN using LDAP authentication. While functional, it did not support the same Single Sign-On experience, centralized authorization model, or identity-based access controls available to the rest of the platform.</p><p>In the next article, I’ll replace OpenVPN with NetBird and extend the same identity model to the network layer.</p><p>We’ll deploy a self-hosted NetBird environment, integrate it with Authentik, synchronize users and groups from Microsoft Entra ID, and enable Single Sign-On for VPN access.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/946/1*1vJknF0Pz1kFjZxX3zXGSw.png" /><figcaption>Target architecture with Netbird instead of OpenVPN</figcaption></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=21a732676809" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Kubernetes Platform from Scratch (Part 3: GitOps Repository Design and Platform…]]></title>
            <link>https://medium.com/@aleksander.chotecki/building-a-kubernetes-platform-from-scratch-part-3-gitops-repository-design-and-platform-92370bd95050?source=rss-8277db1f513f------2</link>
            <guid isPermaLink="false">https://medium.com/p/92370bd95050</guid>
            <category><![CDATA[rancher]]></category>
            <category><![CDATA[gitops]]></category>
            <category><![CDATA[kubernetes]]></category>
            <category><![CDATA[authentik]]></category>
            <category><![CDATA[argo-cd]]></category>
            <dc:creator><![CDATA[Aleksander Chotecki]]></dc:creator>
            <pubDate>Tue, 11 Aug 2026 10:41:14 GMT</pubDate>
            <atom:updated>2026-08-11T10:42:33.325Z</atom:updated>
            <content:encoded><![CDATA[<h3>Building a Kubernetes Platform from Scratch (Part 3: GitOps Repository Design and Platform Workloads)</h3><p>In the <a href="https://medium.com/@aleksander.chotecki/building-a-kubernetes-platform-from-scratch-part-1-design-rke2-deployment-with-ansible-dd85c9aa244d">first article</a> of this series, I focused on building the Kubernetes foundation. Starting with two Proxmox hosts, I designed the platform architecture and used Ansible to deploy a highly available RKE2 cluster running entirely on self-managed infrastructure.</p><p>In the <a href="https://medium.com/@aleksander.chotecki/building-a-kubernetes-platform-from-scratch-part-2-azure-key-vault-integration-and-argocd-2c900c9755a3">second article</a>, I introduced centralized secret management using Azure Key Vault and deployed External Secrets Operator and Argo CD. At that point, the cluster had all the core building blocks required for a modern platform: Kubernetes, GitOps, and centralized secret management.</p><p>However, installing Argo CD is only the beginning. The real challenge is defining how infrastructure, platform services, monitoring, and application workloads are organized, deployed, and managed over time. As the number of services grows, a GitOps repository can quickly become difficult to maintain without a clear structure and deployment strategy.</p><p>In this article, I’ll focus on the GitOps architecture that powers the platform. I’ll explain how I organize the repository using the Apps-of-Apps pattern, Argo CD Projects, and Sync Waves to create clear boundaries between infrastructure, platform services, monitoring, and applications. The repository is structured around dedicated workload layers such as 10-infra, 15-platform, 20-monitoring, and 30-apps, each managed by its own Argo CD root application.</p><p>Once the GitOps structure is in place, I’ll start deploying the first production workloads through Argo CD. This includes infrastructure services such as cert-manager, MetalLB, Traefik, ExternalDNS, Longhorn, Velero, Rancher, and Cloudflared, followed by platform services including Homepage, Authentik, Wiki.js, and GitLab Runner.</p><p>By the end of this article, the platform will be fully managed through GitOps. New infrastructure components, platform services, and applications can be deployed by committing changes to Git, with Argo CD continuously reconciling the desired state of the cluster.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/901/1*f6ASiZFkZYW9oYEMBG0gPw.png" /><figcaption>Target architecture for the GitOps model</figcaption></figure><p>The whole code for this article is avaliable <a href="https://github.com/heyimjustalex/medium/tree/main/Article_3">here</a>.</p><h4>Connecting Argo CD to the GitOps Repository</h4><p>In the previous article, I installed Argo CD and connected it to Azure Key Vault through External Secrets Operator, but I didn’t explain how to connect it to the GitLab instance. Before Argo CD can deploy anything it first needs access to the Git repository that contains the desired state of the platform.</p><p>The first step is creating a dedicated service account for Argo CD in GitLab.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*G1gIRdohhlPgvwXaJgkGQA.jpeg" /><figcaption>Creating a service account in GitLab</figcaption></figure><p>After creating the service account, add it as a member of the GitOps repository with read access permissions.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*khwHDkjzPW2GcKs3FFCHtQ.jpeg" /></figure><p>I prefer using a dedicated account rather than personal credentials because repository access becomes independent of individual users and can be rotated without affecting platform administrators.</p><p>The service account only requires read access to the GitOps repository. After creating the account, I generated a Personal Access Token and stored both the username and token in Azure Key Vault:</p><pre>argocd-gitlab-username<br>argocd-gitlab-token</pre><p>Since I already knew that Authentik would become the central identity provider for the platform, I also prepared the future OIDC integration at this stage. The Authentik OIDC client credentials were generated and added to Azure Key Vault even though Authentik itself had not yet been deployed:</p><pre>argocd-authentik-client-id<br>argocd-authentik-client-secret</pre><p>The OIDC client will be later created inside Authentik, but I had already reserved the corresponding secret names in Azure Key Vault to avoid changing the Argo CD configuration later.</p><p>Finally, I stored the initial Argo CD administrator password hash. Unlike most applications where a plaintext password is provided and hashed internally, Argo CD expects the bcrypt hash itself inside the secret.</p><pre>argocd-admin-password-bcrypthash</pre><p>The resulting Azure Key Vault entries looked as follows:</p><pre>argocd-admin-password-bcrypthash<br>argocd-gitlab-username<br>argocd-gitlab-token<br>argocd-authentik-client-id<br>argocd-authentik-client-secret</pre><p>The following variables map Azure Key Vault secret names to the Argo CD Ansible deployment:</p><pre>akv_argocd_admin_password_key: argocd-admin-password-bcrypthash<br>akv_gitlab_username_key: argocd-gitlab-username<br>akv_gitlab_token_key: argocd-gitlab-token<br>akv_authentik_client_id_key: argocd-authentik-client-id<br>akv_authentik_client_secret_key: argocd-authentik-client-secret</pre><p>Instead of creating repository credentials manually through the Argo CD UI, the playbook (number 15 <a href="https://github.com/heyimjustalex/medium/tree/main/Article_2/ansible_setup_script/playbooks">here</a>) uses External Secrets Operator to retrieve the GitLab username and token from Azure Key Vault and generate a repository secret automatically:</p><pre>- name: Create ExternalSecret for argocd-secret<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: external-secrets.io/v1<br>          kind: ExternalSecret<br>          metadata:<br>            name: argocd-secret<br>            namespace: &quot;{{ argocd_namespace }}&quot;<br>          spec:<br>            refreshInterval: 5m<br><br>            secretStoreRef:<br>              kind: ClusterSecretStore<br>              name: &quot;{{ eso_cluster_secret_store_name }}&quot;<br><br>            target:<br>              name: argocd-secret<br>              creationPolicy: Merge<br><br>            data:<br>              - secretKey: admin.password<br>                remoteRef:<br>                  key: &quot;{{ akv_argocd_admin_password_key }}&quot;<br><br>              - secretKey: dex.authentik.clientId<br>                remoteRef:<br>                  key: &quot;{{ akv_authentik_client_id_key }}&quot;<br><br>              - secretKey: dex.authentik.clientSecret<br>                remoteRef:<br>                  key: &quot;{{ akv_authentik_client_secret_key }}&quot;</pre><p>At the same time, another ExternalSecret populates the main argocd-secret object. Besides the administrator password, it also provisions the future OIDC credentials that Argo CD will later use when Authentik is deployed:</p><pre>data:<br>  - secretKey: admin.password<br>    remoteRef:<br>      key: &quot;{{ akv_argocd_admin_password_key }}&quot;<br>  - secretKey: dex.authentik.clientId<br>    remoteRef:<br>      key: &quot;{{ akv_authentik_client_id_key }}&quot;<br>  - secretKey: dex.authentik.clientSecret<br>    remoteRef:<br>      key: &quot;{{ akv_authentik_client_secret_key }}&quot;</pre><p>Although Authentik was not available yet, this meant that the Argo CD deployment was already prepared for a future migration to OIDC-based authentication.</p><p>Once synchronized, External Secrets Operator generates the required Kubernetes Secrets automatically. Because the repository Secret is labeled as an Argo CD repository Secret, Argo CD immediately discovers it and registers the repository without requiring any manual configuration.</p><p>To verify that synchronization completed successfully:</p><pre>kubectl -n argocd get externalsecret</pre><p>You should also see the generated repository Secret:</p><pre>kubectl -n argocd get secret gitops-repo</pre><p>After synchronization finishes, the repository should appear in the Argo CD UI:</p><pre>Settings<br>└── Repositories</pre><p>The repository should be displayed with a successful connection status, confirming that Argo CD can authenticate and pull manifests directly from Git.</p><p>At this point Argo CD is fully connected to the GitOps repository and ready to manage the entire platform. The next step is designing the repository structure that will allow infrastructure, platform services, monitoring stacks, and application workloads to be deployed independently while remaining part of a single GitOps workflow.</p><p>We can log in with the bootstrap/break-glass account to the ArgoCD now.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/997/1*iuQuJ0xZs6Yl0nvGkOpiEw.png" /><figcaption>Log in with Authentik button is visible now, but still unusable.</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/971/1*xaY57HH_F62BJSLWVYccrw.png" /><figcaption>The ArgoCD settings</figcaption></figure><h4>Designing the GitOps Repository Structure</h4><p>With the repository connected, Argo CD can finally start deploying workloads. Before creating any applications, however, I wanted to establish a repository structure that would remain maintainable as the platform grows.</p><p>One common mistake in GitOps repositories is placing everything into a single directory and allowing Argo CD to deploy every manifest it finds. While this works initially, it quickly becomes difficult to manage once infrastructure services, monitoring stacks, platform components, and application workloads start accumulating.</p><p>To avoid that problem, I separated the repository into several logical layers:</p><pre>0-argocd/<br>├── 0-bootstrap/<br>├── 10-projects/<br>└── 20-applications/<br>10-infra/<br>15-platform/<br>20-monitoring/<br>30-apps/</pre><p>Each directory represents a different responsibility within the platform:</p><p>- 0-argocd -Argo CD configuration and bootstrap</p><p>- 10-infra -Infrastructure services required by the cluster</p><p>- 15-platform -Shared platform services</p><p>- 20-monitoring -Monitoring and observability stack</p><p>- 30-apps -Business and application workloads</p><p>At the top level, I use the <strong>Apps-of-Apps pattern</strong>. Rather than creating every Argo CD Application manually, a small bootstrap application creates a set of root applications that are responsible for discovering workloads in each layer.</p><p>The deployment flow looks like this:</p><pre>gitops-bootstrap<br>        │<br>        ▼<br>argocd-projects<br>        │<br>        ▼<br>argocd-root-applications<br>        │<br>        ├──────────┬────────────┬─────────┐<br>        ▼          ▼            ▼         ▼<br>      Infra     Platform    Monitoring   Apps</pre><p>The first root application deploys all Argo CD Projects:</p><pre>metadata:<br>  name: argocd-projects<br>spec:<br>  source:<br>    path: 0-argocd/10-projects</pre><p>The second root application deploys all workload categories:</p><pre>metadata:<br>  name: argocd-root-applications<br>spec:<br>  source:<br>    path: 0-argocd/20-applications</pre><p>Each root application then recursively discovers individual application.yaml files within a dedicated directory.</p><p>For example, the infrastructure layer is configured as follows:</p><pre>apiVersion: argoproj.io/v1alpha1<br>kind: Application<br>metadata:<br>  name: infra<br>  namespace: argocd<br>  annotations:<br>    argocd.argoproj.io/sync-wave: &quot;0&quot;<br>spec:<br>  project: infra<br>  source:<br>    repoURL: https://gitlab.&lt;your-domain&gt;/cloud-team/lab-infra/gitops.git<br>    targetRevision: main<br>    path: 10-infra<br>    directory:<br>      recurse: true  <br>      include: &quot;*/application.yaml&quot;<br>  destination:<br>    server: https://kubernetes.default.svc<br>    namespace: argocd</pre><pre>  syncPolicy:<br>    automated:<br>      prune: true<br>      selfHeal: true</pre><p>This means that any folder under 10-infra containing an application.yaml file automatically becomes part of the infrastructure layer managed by Argo CD.</p><p>As a result, onboarding a new service becomes extremely simple:</p><pre>10-infra/<br>└── 150-new-service/<br>    ├── application.yaml<br>    ├── values.yaml<br>    └── config/</pre><p>After committing the changes, Argo CD automatically discovers the new application and reconciles it without any additional manual configuration.</p><p>I also use dedicated Argo CD Projects to enforce separation between different workload categories:</p><pre>infra<br>platform<br>monitoring<br>apps</pre><p>This provides a clean security boundary between infrastructure administrators, platform operators, and application teams while still keeping everything managed through a single GitOps repository.</p><p>The final result is a repository structure that scales naturally. Infrastructure, platform services, monitoring stacks, and application workloads can evolve independently while still being deployed through a single GitOps workflow managed entirely by Argo CD.</p><p>The next step is defining deployment ordering so that infrastructure services are installed before platform workloads, monitoring components, and applications.</p><h3>Controlling Deployment Order with Sync Waves</h3><p>With the repository structure in place, the next challenge is deployment ordering.</p><p>Not all components of the platform can be deployed simultaneously. Certain services depend on others being available first. For example, certificates require cert-manager, ingress resources require Traefik, and workload namespaces often depend on infrastructure components already existing.</p><p>To solve this problem, I use Argo CD Sync Waves.</p><p>A Sync Wave is simply an annotation that tells Argo CD in which order resources should be synchronized. Applications with lower values are deployed first, while higher values are deployed later.</p><pre>metadata:<br>  annotations:<br>    argocd.argoproj.io/sync-wave: &quot;20&quot;</pre><p>This allows the entire platform to be deployed automatically while still respecting dependencies between components.</p><p>The deployment flow in my environment looks as follows:</p><pre>-500  Argo CD Projects<br>-300  Root Applications<br>  20  cert-manager<br>  25  ClusterIssuer<br>  30  MetalLB<br>  35  MetalLB Configuration<br>  40  Traefik<br>  45  ExternalDNS Secrets<br>  50  ExternalDNS<br>  55  Argo CD Ingress<br>  60  Rancher<br>  65  Longhorn<br>  70  Velero<br>  75  Cloudflared<br> 500+ Platform Services<br>1000+ Monitoring<br>1500+ Applications</pre><p>The infrastructure layer is deployed first because nearly every other workload depends on it.</p><p>For example, cert-manager is installed before the Let’s Encrypt ClusterIssuer:</p><pre>cert-manager<br>    ↓<br>ClusterIssuer<br>    ↓<br>Certificates</pre><p>The same approach is used throughout the platform. Longhorn and Velero are deployed only after networking and ingress components are available, while platform services are deployed only after the infrastructure layer becomes healthy.</p><p>This ordering also simplifies troubleshooting. If a workload fails to deploy, Argo CD clearly shows the synchronization stage where the deployment stopped, making dependency-related issues significantly easier to identify.</p><p>An additional benefit of Sync Waves is that new services can be inserted into the deployment chain without modifying existing applications. For example, when I introduced Authentik, I simply assigned higher Sync Waves than the infrastructure components:</p><pre>505  Homepage<br>505  Authentik PostgreSQL<br>510  Authentik<br>510  WikiJS PostgreSQL<br>515  WikiJS<br>540  GitLab Runner</pre><p>This ensures that all required infrastructure components are already running before platform services begin deployment.</p><p>As a result, a fresh Argo CD bootstrap can deploy the entire platform from an empty cluster without any manual intervention. Argo CD automatically processes applications in the correct order, turning the Git repository into the single source of truth not only for configuration, but also for deployment orchestration.</p><p>The target outcome of our deployment is an architecture like this.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*eyzS2uH444JOJNpaUBkpkw.png" /><figcaption>Target architecture with workloads deployed to the worker nodes</figcaption></figure><h3>Infrastructure and Platform Workloads</h3><p>With the GitOps repository structure, the Secrets added to the the Azure Key Vault (I’ve omitted this part, you can inspect the ExternalSecrets objects <a href="https://github.com/heyimjustalex/medium/tree/main/Article_3">here</a> to know which secrets to create) and deployment ordering in place, the platform can start deploying actual workloads.</p><p>I divided the environment into four logical layers:</p><pre>10-infra      → Cluster infrastructure<br>15-platform   → Shared platform services<br>20-monitoring → Observability stack<br>30-apps       → Application workloads</pre><p>The infrastructure layer contains all components required to operate the cluster itself.</p><h3>cert-manager</h3><p>The first infrastructure component is cert-manager. It is responsible for automatically issuing and renewing TLS certificates across the platform.</p><p>Certificates are issued through Let’s Encrypt using the DNS-01 challenge. A Cloudflare API token is stored in Azure Key Vault, synchronized into the cluster using External Secrets Operator, and then consumed by a ClusterIssuer used by cert-manager.</p><p>This allows certificates to be provisioned and renewed automatically without any manual intervention.</p><h3>MetalLB</h3><p>Since this platform runs on-premises rather than in a public cloud, there is no cloud provider load balancer available.</p><p>MetalLB solves this problem by providing LoadBalancer functionality inside the Kubernetes cluster. It is responsible for allocating IP addresses from predefined address pools and exposing services to the network.</p><p>Without MetalLB, services using:</p><pre>type: LoadBalancer</pre><p>would remain in a pending state because Kubernetes would have no mechanism for assigning external IP addresses.</p><p>The deployment is split into two parts:</p><pre>MetalLB Core<br>        │<br>        ▼<br>MetalLB Configuration</pre><p>This ensures that the controller is available before address pools and advertisements are applied.</p><h3>Traefik</h3><p>Traefik acts as the platform’s ingress controller.</p><p>It is responsible for:</p><ul><li>Terminating TLS connections</li><li>Routing traffic to workloads</li><li>Loading TLS certificates generated by cert-manager</li><li>Processing IngressRoute resources</li><li>Providing a single entry point for all HTTP and HTTPS traffic</li></ul><p>Most platform services are exposed through Traefik.</p><p>A typical traffic flow looks like:</p><pre>Internet<br>    │<br>    ▼ <br> Traefik<br>    │<br>    ▼<br>IngressRoute<br>    │<br>    ▼<br> Service<br>    │<br>    ▼<br>   Pod</pre><p>The below diagram shows how the above components cooperate to provision the IPs, routing and certificates with the help of Cloudflare (where I manage my domain).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ldNF05gnV3mScwwwmLl7Ug.png" /><figcaption>Certificate renewal workflow</figcaption></figure><h3>ExternalDNS</h3><p>Managing DNS records manually quickly becomes difficult as new services are deployed. ExternalDNS solves this problem by watching Kubernetes resources and automatically creating DNS records in Cloudflare.</p><p>The Cloudflare token is retrieved from Azure Key Vault through External Secrets Operator, meaning no DNS credentials are hardcoded inside Git repositories.</p><p>A typical flow looks as follows:</p><pre>IngressRoute<br>        │<br>        ▼<br>ExternalDNS<br>        │<br>        ▼<br>Cloudflare DNS Record</pre><p>As soon as a new application exposes a hostname, ExternalDNS automatically creates the corresponding DNS entry.</p><h3>Cloudflared</h3><p>The platform also integrates with Cloudflare using Cloudflare Tunnel.</p><p>Instead of exposing services directly to the internet through port forwarding, Cloudflared creates an outbound tunnel from the Kubernetes cluster to Cloudflare.</p><p>The resulting architecture looks like this:</p><pre>Internet<br>    │<br>    ▼<br>Cloudflare<br>    │<br>    ▼<br>Cloudflare Tunnel<br>    │<br>    ▼<br>Cloudflared<br>    │<br>    ▼<br> Traefik<br>    │<br>    ▼<br>Application</pre><p>This approach provides several advantages:</p><ul><li>No inbound ports need to be opened</li><li>Public services can remain behind Cloudflare</li><li>Additional Cloudflare security controls can be applied</li><li>Origin infrastructure remains hidden from the internet</li></ul><p>To improve availability, Cloudflared runs multiple replicas.</p><h3>Rancher</h3><p>Rancher provides a centralized management interface for the Kubernetes environment.</p><p>While most platform operations are performed through GitOps and Argo CD, Rancher remains useful for:</p><ul><li>Cluster administration</li><li>Node management</li><li>Workload inspection</li><li>Troubleshooting</li><li>User access management (kubeconfigs)</li></ul><p>The initial Rancher bootstrap password is also synchronized from Azure Key Vault using External Secrets Operator, ensuring that administrative credentials never need to be stored in Git.</p><h3>Longhorn</h3><p>Longhorn provides distributed block storage for the platform.</p><p>Persistent Volumes are automatically replicated across cluster nodes, allowing workloads to survive individual node failures.</p><p>Longhorn is used by multiple platform components that require persistent storage.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/630/1*aYf4Il_QdibGzIitTbagzQ.png" /><figcaption>Longhorn architecture (src. Longhorn documentation)</figcaption></figure><p>The deployment also creates recurring backup jobs which automatically export Longhorn backups to Azure Blob Storage according to a defined retention policy.</p><p>Remember to disable this setting in Longhorn, so that Longhorn replicas can be distributed across different zones and failure domains. In my case, the Kubernetes nodes that are hosted on a different bare metal Proxmox node have different zone labels (this configuration is part of Ansible scripts).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*i8hPVQFC0pE6Yo7F5a6lxg.png" /></figure><h3>Velero</h3><p>Velero complements Longhorn by providing Kubernetes-level backup and disaster recovery capabilities.</p><p>While Longhorn focuses on persistent volume data, Velero captures cluster resources such as:</p><ul><li>Deployments</li><li>Services</li><li>ConfigMaps</li><li>Secrets</li><li>Namespaces</li><li>Persistent Volume metadata</li></ul><p>Backups are automatically scheduled and stored in Azure Blob Storage (if you configure proper Azure Key Vault credentials for the Blob Storage).</p><p>This provides an additional recovery layer beyond volume-level backups and allows complete workload restoration if necessary.</p><p>With the infrastructure layer deployed, the cluster now has everything required to host higher-level services:</p><pre>Certificates<br>Networking<br>Ingress<br>Load Balancing<br>DNS Automation<br>Cloudflare Integration<br>Persistent Storage<br>Backups<br>Cluster Management</pre><p>The next layer builds on top of this foundation and introduces shared platform services such as Homepage, Authentik, Wiki.js, and GitLab Runner.</p><h3>Deploying Platform Services</h3><p>With the infrastructure layer fully operational, the cluster now provides everything required to host higher-level workloads.</p><p>At this point, the platform has:</p><ul><li>TLS certificate automation</li><li>DNS automation</li><li>Ingress routing</li><li>Cloudflare integration</li><li>Persistent storage</li><li>Backup and recovery</li><li>Cluster management</li></ul><p>The next layer consists of platform services. These workloads are not strictly required for Kubernetes itself to function, but they provide capabilities that make the platform usable by administrators and application teams.</p><p>In my environment, the platform layer currently contains:</p><pre>Homepage<br>Authentik<br>Wiki.js<br>GitLab Runner</pre><p>Unlike the infrastructure layer, these workloads depend on services that were deployed previously. For example, they consume TLS certificates issued by cert-manager, use storage provisioned by Longhorn, and expose endpoints through Traefik and Cloudflare.</p><h3>Homepage</h3><p>Homepage acts as the landing page for the platform.</p><p>As the number of services increased, keeping track of URLs, dashboards, and administrative interfaces becomes increasingly difficult. Homepage provides a centralized dashboard where platform services can be organized and accessed from a single location.</p><p>The service itself is deployed through Argo CD as a standard Helm chart and follows the same GitOps workflow as every other workload in the environment.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EDhHxQg8890fjWai4N0PMw.png" /><figcaption>The final Homepage app view we should be able to get in the end of the series.</figcaption></figure><h3>Authentik</h3><p>One of the primary goals of this platform is centralized identity and access management.</p><p>While Argo CD was already prepared for OIDC integration in the previous article, the identity provider itself had not yet been deployed.</p><p>To solve this problem, I chose Authentik.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YJ16rqpCIpiHNk03JjhveQ.png" /><figcaption>Authentik IDP should be used as an IDP for all the services and it should be federated with EntraID so that users can log-in via their corporate IDP.</figcaption></figure><p>Up to this point, most services could be accessed using local accounts, bootstrap credentials, or service-specific authentication mechanisms. While this approach works for a small number of services, it quickly becomes difficult to manage as the platform grows.</p><p>I wanted a single identity provider that could handle authentication for platform services such as Argo CD, Rancher, Grafana, NetBird, and future applications deployed to the cluster. After evaluating several options, I chose Authentik.</p><p>The main reasons were:</p><ul><li>Fully self-hosted</li><li>Native OIDC and SAML support</li><li>Flexible group-based authorization</li><li>Straightforward Kubernetes deployment</li><li>Excellent integration with cloud-native platforms</li></ul><p>Since Argo CD had already been prepared for future OIDC integration in the previous section, introducing Authentik later would not require significant modifications to the GitOps structure.</p><h3>Authentik PostgreSQL</h3><p>Authentik requires a PostgreSQL database backend.</p><p>Rather than deploying the database manually, I created a dedicated Argo CD application responsible for PostgreSQL deployment.</p><p>The PostgreSQL deployment uses Longhorn-backed persistent storage, ensuring that database data survives pod restarts, node maintenance events, and cluster upgrades.</p><p>As with every other platform component, the deployment is managed entirely through Argo CD and GitOps.</p><h3>Authentik Secrets from Azure Key Vault</h3><p>One of the advantages of introducing Azure Key Vault and External Secrets Operator earlier in the series is that new workloads automatically inherit the same secret management model.</p><p>Authentik requires a secret key used internally for cryptographic operations. Rather than storing this value in Git or directly in Kubernetes, it is maintained in Azure Key Vault and synchronized through External Secrets Operator.</p><pre>apiVersion: external-secrets.io/v1<br>kind: ExternalSecret<br>metadata:<br>  name: authentik-secret<br>  namespace: authentik<br>spec:<br>  refreshInterval: 10m<br>  secretStoreRef:<br>    kind: ClusterSecretStore<br>    name: azure-kv<br>  target:<br>    name: authentik-secret<br>    creationPolicy: Owner<br>  data:<br>    - secretKey: AUTHENTIK_SECRET_KEY<br>      remoteRef:<br>        key: authentik-secret-key</pre><h3>Authentik Application</h3><p>Like every other platform service, Authentik is deployed through its own Argo CD Application.</p><p>The application definition follows the same structure used throughout the repository:</p><pre>15-platform/<br>└── authentik/<br>    ├── application.yaml<br>    ├── values.yaml<br>    └── config/</pre><p>The Application references the Authentik Helm chart, applies platform-specific configuration, and enables automated synchronization.</p><p>Because it lives in the 15-platform layer, it is automatically discovered by the root Platform application without requiring any additional Argo CD configuration.</p><p>Adding a new platform service follows exactly the same workflow:</p><pre>Create a folder<br>Commit changes<br>Push to Git<br>Argo CD deploys automatically</pre><h3>Authentik IngressRoute</h3><p>Once Authentik is deployed, it needs to be exposed externally.</p><p>Like the rest of the platform, incoming traffic is routed through Traefik, but in this case we use cloudflared to expose it via the Cloudflare Tunnel.</p><p>The resulting request path looks like this:</p><pre>Internet<br>     │<br>     ▼<br>Cloudflare<br>     │<br>     ▼<br>Cloudflare Tunnel<br>     │<br>     ▼<br>Cloudflared<br>     │<br>     ▼<br>Traefik<br>     │<br>     ▼<br>Authentik</pre><p>The manifest should look like this:</p><pre># For cloudflare DNS record should not be created with extenal-dns, so here we remove external dns annotation<br>apiVersion: traefik.io/v1alpha1<br>kind: IngressRoute<br>metadata:<br>  name: authentik-cloudflared<br>  namespace: authentik<br>spec:<br>  entryPoints:<br>    - cloudflared<br>  routes:<br>    - match: Host(`auth.&lt;your_domain&gt;`)<br>      kind: Rule<br>      services:<br>        - name: authentik-server<br>          port: 80<br>  # <br>  # tls:<br>  #   secretName: authentik-tls</pre><p>When using cloudflared the TLS will be provided by the Cloudflare, we don’t need to configure the cert-manager Certificate object for it. The entryPoint configuration corresponds to this Traefik configuration:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/590/1*KxxQvUsLagg7niK0t79VQQ.png" /><figcaption>Traefik values.yaml file that exposes multiple entrypoints</figcaption></figure><p>In the Cloudflare Tunnel options you need to point your domain you want to publish the Authentik instance with to the Traefik service like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*bJK3Y1ag2H0kJKiHjxvuiA.png" /><figcaption>Exposing the service via the cloudflare tunnel</figcaption></figure><p>This approach avoids exposing Kubernetes nodes directly to the internet while still allowing secure access to the Authentik portal.</p><p>The only public endpoint visible to users is the Authentik hostname:</p><pre>https://auth.&lt;your_domain&gt;.com</pre><p><strong>However, after opening the link you should be prompted with registration of your first account. This is not safe, as you expose the installation to the Internet.</strong> What I did instead, I’ve disabled the published route in Cloudflare and I’ve installed it by exposing it to the VPN network and getting the certificate for a different domain (authentik.&lt;your_domain&gt;.com).</p><pre>apiVersion: cert-manager.io/v1<br>kind: Certificate<br>metadata:<br>  name: authentik-tls<br>  namespace: authentik<br>spec:<br>  secretName: authentik-tls<br>  issuerRef:<br>    name: letsencrypt-prod<br>    kind: ClusterIssuer<br>  dnsNames:<br>    # auth.&lt;your_domain&gt; has cert managed by cloudflare tunnel<br>   # - auth.&lt;your_domain&gt;<br>   # this domain is only for internal VPN-based access. Using it for SSO will fail.<br>   - authentik.&lt;your_domain&gt;<br>---<br>apiVersion: traefik.io/v1alpha1<br>kind: IngressRoute<br>metadata:<br>  name: authentik<br>  namespace: authentik<br>  annotations:<br>    external-dns.alpha.kubernetes.io/target: ingress.&lt;your_domain&gt;<br>spec:<br>  entryPoints:<br>    - websecure<br>  routes:<br>    - match: Host(`authentik.&lt;your_domain&gt;`)<br>      kind: Rule<br>      services:<br>        - name: authentik-server<br>          port: 80<br>  tls:<br>    secretName: authentik-tls</pre><p>After creating the initial account via the VPN network the instance should be operational and can be exposed via the cloudflared. For additional security layer you can configure Cloudflare WAF, but you should allowlist traffic originating from the cluster egress IP address as the ‘managed-challenges’ can break the OIDC integrations we will make in the future articles.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*hmFPIba3wka25-NLPUV7jw.png" /><figcaption>Cloudflare basic WAF rules (free tier).</figcaption></figure><h3>Future Integrations</h3><p>At this stage, Authentik is deployed and operational, but it is not yet serving as the central identity provider for the platform.</p><p>The goal is to gradually migrate services toward centralized authentication using OIDC.</p><p>Several services have already been prepared for that transition. For example, Argo CD is configured to retrieve future Authentik OIDC client credentials from Azure Key Vault through External Secrets Operator. This allows OIDC integration to be enabled later without changing the overall GitOps architecture.</p><p>In future articles, I will integrate Authentik with:</p><ul><li>Argo CD</li><li>Rancher</li><li>Grafana</li><li>NetBird</li></ul><p>The end goal is to have a single authentication platform providing centralized identity management, group-based authorization, and single sign-on across the entire Kubernetes environment.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YJ16rqpCIpiHNk03JjhveQ.png" /><figcaption>Authentik IDP should be used as an IDP for all the services and it will eventually be federated with Microsoft Entra ID, allowing users to authenticate using corporate identities.<br>This integration will be covered in a future article.</figcaption></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=92370bd95050" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Kubernetes Platform from Scratch (Part 2: Azure Key Vault Integration and ArgoCD…]]></title>
            <link>https://medium.com/@aleksander.chotecki/building-a-kubernetes-platform-from-scratch-part-2-azure-key-vault-integration-and-argocd-2c900c9755a3?source=rss-8277db1f513f------2</link>
            <guid isPermaLink="false">https://medium.com/p/2c900c9755a3</guid>
            <category><![CDATA[ansible]]></category>
            <category><![CDATA[azure-key-vault]]></category>
            <category><![CDATA[argo-cd]]></category>
            <category><![CDATA[rke2]]></category>
            <category><![CDATA[kubernetes]]></category>
            <dc:creator><![CDATA[Aleksander Chotecki]]></dc:creator>
            <pubDate>Fri, 07 Aug 2026 09:44:45 GMT</pubDate>
            <atom:updated>2026-08-07T09:52:46.046Z</atom:updated>
            <content:encoded><![CDATA[<h3>Building a Kubernetes Platform from Scratch (Part 2: Azure Key Vault Integration and ArgoCD deployment)</h3><p>In the <a href="https://medium.com/@aleksander.chotecki/building-a-kubernetes-platform-from-scratch-part-1-design-rke2-deployment-with-ansible-dd85c9aa244d">previous article</a>, I focused on building the Kubernetes foundation itself. Starting with two bare-metal servers running Proxmox, I designed the cluster architecture, created the virtual machines, and used Ansible to automate the deployment of a highly available RKE2 cluster. By the end of that process, I had a working Kubernetes environment with three control-plane nodes and four worker nodes running entirely on infrastructure that I manage myself.</p><p>Getting the cluster running was only the first step though. While Kubernetes can store secrets natively, that approach quickly becomes difficult to manage as the platform grows. Database passwords, API keys, certificates, and other sensitive configuration end up scattered across multiple namespaces and applications. I wanted a single place to manage secrets while keeping them outside the cluster itself.</p><p>In this article, I’ll integrate the platform with Azure Key Vault.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gzqzwGljnxea0E6Ja2SXUQ.png" /><figcaption>Target architecture for my RKE2 cluster</figcaption></figure><p>Even though the Kubernetes cluster is running on-premises, Azure Key Vault provides a reliable and centralized way of storing secrets. The goal is to use Azure as the source of truth for sensitive data, while still allowing Kubernetes workloads to consume those secrets using standard Kubernetes mechanisms.</p><p>All of the code for this solution is available in this <a href="https://github.com/heyimjustalex/medium/tree/main/Article_2">GitHub repository</a>.</p><h3>Azure Blob Storage for Terraform State</h3><p>Before creating Azure Key Vault, I wanted to solve a different problem first: Terraform state management.</p><p>For small projects, storing the Terraform state file locally is usually sufficient. As the number of resources grows, however, relying on a local terraform.tfstate file becomes less practical. The state file contains information about all managed resources, and losing it can make future changes significantly more difficult.</p><p>To avoid that situation, I decided to store Terraform state in Azure Blob Storage. This gives me a central location for Terraform state while also making it easier to manage future Azure resources from multiple workstations if needed.</p><p>There is one small catch though: before Terraform can use Azure Blob Storage as a backend, the storage account itself must already exist. This creates a bootstrap problem because Terraform cannot store its state in a backend that hasn’t been created yet.</p><p>To solve this, I use a separate Terraform configuration with local state. Its sole purpose is to create the storage account and container that will later be used as the remote backend. Once the backend exists, all subsequent Terraform projects can switch to using Azure Blob Storage.</p><h4><strong>Creating a Service Principal for Terraform</strong></h4><p>I don’t want Terraform to authenticate using my personal Azure account. Instead, I create a dedicated Service Principal that Terraform will use whenever it interacts with Azure resources.</p><p>First, I select the correct subscription:</p><pre>az account set  --subscription &lt;SUBSCRIPTION-ID&gt;<br>az account show<br></pre><p>Next, I create a Service Principal with Contributor permissions scoped to the resource group that will contain the Kubernetes-related resources:</p><pre>az ad sp create-for-rbac \<br>  --name &lt;TERRAFORM_SP_NAME&gt; \<br>  --role Contributor \<br>  --scopes \<br>  &quot;/subscriptions/&lt;SUBSCRIPTION-ID&gt;/resourceGroups/&lt;RESOURCE-GROUP-NAME&gt;&quot;</pre><p>In the Azure CLI output, the password field represents the Service Principal client secret.</p><pre>{<br>  &quot;appId&quot;: &quot;xxxxxxxxxxx&quot;,<br>  &quot;displayName&quot;: &quot;&lt;TERRAFORM_SP_NAME&gt;&quot;,<br>  &quot;password&quot;: &quot;xxxxxxxxxxx&quot;,<br>  &quot;tenant&quot;: &quot;xxxxxxxxxxxxxxxx&quot;<br>}</pre><p>These credentials should be treated as sensitive information and never committed to the source control system.</p><h4><strong>Granting Access to Azure Blob Storage</strong></h4><p>The Contributor role is not sufficient for managing Terraform state stored in Azure Storage. Terraform also needs permissions to read and write blobs.</p><p>To allow that, I assign the Storage Blob Data Contributor role to the Service Principal:</p><pre>az role assignment create \<br>  --assignee &quot;&lt;APPLICATION_CLIENT_ID&gt;&quot; \<br>  --role &quot;Storage Blob Data Contributor&quot; \<br>  --scope &quot;/subscriptions/&lt;SUBSCRIPTION-ID&gt;/resourceGroups/&lt;RESOURCE-GROUP-NAME&gt;&quot;</pre><p>While setting up Azure Key Vault, I ran into a limitation that wasn’t immediately obvious. Even though the Terraform Service Principal had the <strong>Contributor</strong> role on the resource group, Terraform was unable to manage certain Key Vault access configurations.</p><p>The reason is that the <strong>Contributor</strong> role allows creating and modifying Azure resources, but it does not allow creating or modifying Azure RBAC role assignments. This becomes important when Terraform is expected to manage access permissions for Azure Key Vault.</p><p>To fix this, I granted the Terraform Service Principal the <strong>User Access Administrator</strong> role on the resource group:</p><pre>az role assignment create \<br>  --assignee &lt;TERRAFORM_SERVICE_PRINCIPAL_ID&gt; \<br>  --role &quot;User Access Administrator&quot; \<br>  --scope &quot;/subscriptions/&lt;SUBSCRIPTION-ID&gt;/resourceGroups/&lt;RESOURCE-GROUP-NAME&gt;&quot;</pre><p>This additional permission allows Terraform to create and manage Azure RBAC assignments, which is required when provisioning identities and assigning Key Vault permissions through Infrastructure-as-Code.</p><h4>Bootstrapping the Backend Storage Account</h4><p>The next step is creating the storage account that will hold Terraform state.</p><p>This Terraform configuration intentionally uses the default local backend because its only responsibility is creating the remote backend infrastructure itself.</p><p>The storage account is configured with several security controls:</p><ul><li>TLS 1.2 enforced</li><li>Public blob access disabled</li><li>Blob versioning enabled</li><li>Soft-delete policies enabled</li><li>Network restrictions allowing access only from approved IP ranges</li><li>Private container for Terraform state files</li></ul><p>The most important resource in the configuration is the storage account itself:</p><pre>terraform {<br>  required_providers {<br>    azurerm = {<br>      source  = &quot;hashicorp/azurerm&quot;<br>      version = &quot;~&gt; 4.2&quot;<br>    }<br><br>    http = {<br>      source  = &quot;hashicorp/http&quot;<br>      version = &quot;~&gt; 3.0&quot;<br>    }<br><br>  }<br>}<br><br>provider &quot;azurerm&quot; {<br>  features {}<br>}<br><br><br>data &quot;http&quot; &quot;my_ip&quot; {<br>  url = &quot;https://api.ipify.org?format=json&quot;<br>}<br><br><br># ----------------------------<br># Resource group<br># ----------------------------<br>data &quot;azurerm_resource_group&quot; &quot;rg&quot; {<br>  name = &quot;&lt;your_domain&gt;-k8s-cluster_RG&quot;<br>}<br><br># ----------------------------<br># Locals (naming + IP)<br># ----------------------------<br>locals {<br>  storage_account_name = &quot;&lt;your_domain&gt;blobtfstate&quot;<br>  my_ip                = &quot;${jsondecode(data.http.my_ip.response_body).ip}/30&quot;<br><br><br>  allowed_ips = [<br>    &quot;insert-on-prem-cluster-IP/30&quot;,<br><br>  ]<br>  ip_rules = concat([local.my_ip], local.allowed_ips)<br>}<br><br># ----------------------------<br># Storage account (TF STATE)<br># ----------------------------<br>resource &quot;azurerm_storage_account&quot; &quot;tfstate&quot; {<br>  name                = local.storage_account_name<br>  resource_group_name = data.azurerm_resource_group.rg.name<br>  location            = data.azurerm_resource_group.rg.location<br><br>  account_tier             = &quot;Standard&quot;<br>  account_replication_type = &quot;LRS&quot;<br>  account_kind             = &quot;StorageV2&quot;<br><br>  min_tls_version                 = &quot;TLS1_2&quot;<br>  allow_nested_items_to_be_public = false<br><br>  blob_properties {<br>    versioning_enabled = true<br><br>    delete_retention_policy {<br>      days = 30<br>    }<br><br>    container_delete_retention_policy {<br>      days = 7<br>    }<br><br>  }<br><br>  network_rules {<br>    default_action = &quot;Deny&quot;<br>    bypass         = [&quot;AzureServices&quot;]<br>    ip_rules       = local.ip_rules<br>  }<br><br>  tags = {<br>    purpose     = &quot;terraform-state&quot;<br>    environment = &quot;&lt;your_domain&gt;-prd&quot;<br>    managed_by  = &quot;terraform&quot;<br>  }<br>}<br><br># ----------------------------<br># Container<br># ----------------------------<br>resource &quot;azurerm_storage_container&quot; &quot;tfstate&quot; {<br>  name                  = &quot;tfstate&quot;<br>  storage_account_id    = azurerm_storage_account.tfstate.id<br>  container_access_type = &quot;private&quot;<br>}</pre><p>The variables.tf contents:</p><pre>variable &quot;subscription_id&quot; {}<br>variable &quot;client_id&quot; {}<br>variable &quot;client_secret&quot; {<br>  sensitive = true<br>}<br>variable &quot;tenant_id&quot; {}</pre><h4>Authenticating Terraform</h4><p>Let’s use environment variables for authentication to Azure Cloud:</p><pre>export ARM_CLIENT_ID=&quot;...&quot;<br>export ARM_CLIENT_SECRET=&quot;...&quot;<br>export ARM_TENANT_ID=&quot;...&quot;<br>export ARM_SUBSCRIPTION_ID=&quot;...&quot;</pre><h4>Deploying the Backend</h4><p>Once everything is configured, the bootstrap infrastructure can be deployed as any other Terraform project:</p><pre>terraform init<br>terraform plan<br>terraform apply -auto-approve</pre><p>After the deployment completes, the Azure Storage Account and the tfstate container become available. From this point onward, all future Terraform projects can use Azure Blob Storage as their remote backend instead of relying on local state files.</p><p>In the next step, I’ll use this newly created backend to provision and manage Azure Key Vault itself.</p><h4>Initializing Azure Key Vault</h4><p>In the previous section, I created the Azure Blob Storage account that will be used as the Terraform backend. The storage account was bootstrapped using local state because, at that point, the remote backend did not exist yet.</p><p>Now that the backend is available, I can create the actual Terraform project that will manage the Azure resources used by the Kubernetes platform. The code is available <a href="https://github.com/heyimjustalex/medium/tree/main/Article_2">here</a>. Instead of mixing everything into the bootstrap code, I prefer keeping the backend provisioning separate from the infrastructure it supports. The first project creates the Terraform backend, while the second project becomes the source of truth for platform-related Azure resources.</p><p>This project will manage Azure Key Vault and it will also contain additional components such as backup storage used by Longhorn and Velero.</p><h4>Configuring the Remote Backend</h4><p>Unlike the bootstrap project, this Terraform configuration stores its state in Azure Blob Storage.</p><p>The backend configuration points Terraform to the storage account and container created in the previous step:</p><p><em>00-provider-backend.tf</em></p><pre>terraform {<br>  required_providers {<br>    azurerm = {<br>      source  = &quot;hashicorp/azurerm&quot;<br>      version = &quot;~&gt; 4.2&quot;<br>    }<br><br>    azuread = {<br>      source  = &quot;hashicorp/azuread&quot;<br>      version = &quot;~&gt; 3.0&quot;<br>    }<br><br>    http = {<br>      source  = &quot;hashicorp/http&quot;<br>      version = &quot;~&gt; 3.0&quot;<br>    }<br>  }<br><br>  backend &quot;azurerm&quot; {<br>    resource_group_name  = &quot;&lt;your_domain&gt;-k8s-cluster_RG&quot;<br>    storage_account_name = &quot;&lt;your_domain&gt;blobtfstate&quot;<br>    container_name       = &quot;tfstate&quot;<br>    key                  = &quot;terraform.tfstate&quot;<br>  }<br>}<br><br>provider &quot;azurerm&quot; {<br>  features {}<br>}<br>Execute </pre><pre>terraform init</pre><p>From this point onward, Terraform state is stored remotely inside Azure Blob Storage rather than on my workstation. This gives me versioned and centrally managed state while avoiding the risk of losing the state file after rebuilding my local machine.</p><h4>Creating the Key Vault</h4><p>The next step is provisioning Azure Key Vault itself and the blob storage for our future backups for our services (Velero, Longhorn and GitLab).</p><p>My goal is to use Azure Key Vault as the central source of truth for secrets consumed by Kubernetes. Instead of storing passwords, tokens, and API keys directly inside Kubernetes manifests, everything will be stored and managed in Azure.</p><p>The Key Vault itself is intentionally configured with several security controls enabled by default:</p><p><em>10-key-vault.tf</em></p><pre>data &quot;azurerm_resource_group&quot; &quot;rg&quot; {<br>  name = var.resource_group_name<br>}<br><br>data &quot;azurerm_client_config&quot; &quot;current&quot; {}<br><br>data &quot;http&quot; &quot;my_ip&quot; {<br>  url = &quot;https://api.ipify.org?format=json&quot;<br>}<br><br>locals {<br>  current_ip = jsondecode(data.http.my_ip.response_body).ip<br><br>  tags = {<br>    environment = var.environment<br>    managed_by  = &quot;terraform&quot;<br>  }<br>}<br><br>resource &quot;azurerm_key_vault&quot; &quot;main&quot; {<br>  name                = var.key_vault_name<br>  location            = data.azurerm_resource_group.rg.location<br>  resource_group_name = data.azurerm_resource_group.rg.name<br><br>  tenant_id = data.azurerm_client_config.current.tenant_id<br><br>  sku_name = &quot;standard&quot;<br><br>  enable_rbac_authorization = true<br><br>  purge_protection_enabled   = true<br>  soft_delete_retention_days = 90<br><br>  public_network_access_enabled = true<br><br>  network_acls {<br>    default_action = &quot;Deny&quot;<br>    bypass         = &quot;AzureServices&quot;<br><br>    ip_rules = concat(<br>      [local.current_ip],<br>      var.allowed_ips<br>    )<br>  }<br><br>  tags = local.tags<br>}<br><br></pre><p>A few settings are worth highlighting here:</p><ul><li><strong>Soft Delete</strong> is enabled, allowing accidentally deleted secrets to be recovered.</li><li><strong>Purge Protection</strong> prevents permanent deletion of vault contents before the retention period expires.</li><li><strong>Network ACLs</strong> default to deny access and only allow explicitly defined IP ranges.</li><li><strong>Azure Services bypass</strong> is enabled so Azure-managed services can still communicate with the vault when required.</li></ul><p>For this lab setup, I keep public access enabled while restricting access to specific IP ranges. In a larger production environment, I would likely replace this with private endpoints.</p><h4>Creating Backup Storage for Longhorn, Velero and GitLab</h4><p><em>20-blob-storage.tf</em></p><p>Rather than creating separate storage accounts for each service, I decided to provision a dedicated backup storage account and isolate the workloads using separate blob containers.</p><p>The storage account follows the same security principles as the Terraform backend:</p><ul><li>TLS 1.2 enforced</li><li>Public blob access disabled</li><li>Storage account firewall enabled</li><li>Default deny network policy</li><li>Soft-delete enabled</li><li>Blob versioning enabled</li></ul><p>To avoid accidentally locking myself out during deployments, Terraform automatically adds the public IP address of the workstation executing the deployment together with a list of predefined trusted IP ranges. This allows Terraform to manage the storage account while still keeping network access restricted.</p><pre><br>locals {<br>  dynamic_runner_ip = &quot;${jsondecode(data.http.my_ip.response_body).ip}/30&quot;<br>  ip_rules = concat(<br>    [local.dynamic_runner_ip],<br>    var.allowed_ips<br>  )<br>}<br><br># ----------------------------<br># Backup storage account<br># ----------------------------<br>resource &quot;azurerm_storage_account&quot; &quot;backup&quot; {<br>  name                = var.backup_storage_account_name<br>  resource_group_name = data.azurerm_resource_group.rg.name<br>  location            = data.azurerm_resource_group.rg.location<br><br>  account_tier             = &quot;Standard&quot;<br>  account_replication_type = &quot;LRS&quot;<br>  account_kind             = &quot;StorageV2&quot;<br><br>  min_tls_version                 = &quot;TLS1_2&quot;<br>  allow_nested_items_to_be_public = false<br><br>  blob_properties {<br>    versioning_enabled = true<br><br>    delete_retention_policy {<br>      days = var.blob_soft_delete_days<br>    }<br><br>    container_delete_retention_policy {<br>      days = var.container_soft_delete_days<br>    }<br>  }<br><br>  network_rules {<br>    default_action = &quot;Deny&quot;<br>    bypass         = [&quot;AzureServices&quot;]<br>    ip_rules       = local.ip_rules<br>  }<br><br>  tags = local.tags<br>}<br><br># ----------------------------<br># Containers<br># ----------------------------<br>resource &quot;azurerm_storage_container&quot; &quot;longhorn&quot; {<br>  name                  = &quot;longhorn&quot;<br>  storage_account_id    = azurerm_storage_account.backup.id<br>  container_access_type = &quot;private&quot;<br>}<br><br>resource &quot;azurerm_storage_container&quot; &quot;velero&quot; {<br>  name                  = &quot;velero&quot;<br>  storage_account_id    = azurerm_storage_account.backup.id<br>  container_access_type = &quot;private&quot;<br>}<br><br><br># ----------------------------<br># Lifecycle / retention rules<br># ----------------------------<br>resource &quot;azurerm_storage_management_policy&quot; &quot;backup_retention&quot; {<br>  storage_account_id = azurerm_storage_account.backup.id<br><br>  # ----------------------------<br>  # Longhorn<br>  # ----------------------------<br>  rule {<br>    name    = &quot;longhorn-retention&quot;<br>    enabled = true<br><br>    filters {<br>      prefix_match = [&quot;longhorn/&quot;]<br>      blob_types   = [&quot;blockBlob&quot;]<br>    }<br><br>    actions {<br>      base_blob {<br>        tier_to_cool_after_days_since_modification_greater_than = var.tier_to_cool_after_days_since_modification_greater_than<br><br>        delete_after_days_since_modification_greater_than = var.longhorn_retention_days<br>      }<br><br>      snapshot {<br>        delete_after_days_since_creation_greater_than = var.longhorn_retention_days<br>      }<br><br>      version {<br>        delete_after_days_since_creation = var.longhorn_retention_days<br>      }<br>    }<br>  }<br><br>  # ----------------------------<br>  # Velero<br>  # ----------------------------<br>  rule {<br>    name    = &quot;velero-retention&quot;<br>    enabled = true<br><br>    filters {<br>      prefix_match = [&quot;velero/&quot;]<br>      blob_types   = [&quot;blockBlob&quot;]<br>    }<br><br>    actions {<br>      base_blob {<br>        tier_to_cool_after_days_since_modification_greater_than = var.tier_to_cool_after_days_since_modification_greater_than<br><br>        delete_after_days_since_modification_greater_than = var.velero_retention_days<br>      }<br><br>      snapshot {<br>        delete_after_days_since_creation_greater_than = var.velero_retention_days<br>      }<br><br>      version {<br>        delete_after_days_since_creation = var.velero_retention_days<br>      }<br>    }<br>  }<br>  rule {<br>    name    = &quot;gitlab-retention&quot;<br>    enabled = true<br><br>    filters {<br>      prefix_match = [&quot;gitlab/&quot;]<br>      blob_types   = [&quot;blockBlob&quot;]<br>    }<br><br>    actions {<br>      base_blob {<br>        tier_to_cool_after_days_since_modification_greater_than = var.tier_to_cool_after_days_since_modification_greater_than<br><br>        delete_after_days_since_modification_greater_than = var.gitlab_retention_days<br>      }<br><br>      snapshot {<br>        delete_after_days_since_creation_greater_than = var.gitlab_retention_days<br>      }<br><br>      version {<br>        delete_after_days_since_creation = var.gitlab_retention_days<br>      }<br>    }<br>  }<br>}<br><br>resource &quot;azurerm_storage_container&quot; &quot;gitlab&quot; {<br>  name                  = &quot;gitlab&quot;<br>  storage_account_id    = azurerm_storage_account.backup.id<br>  container_access_type = &quot;private&quot;<br>}<br><br></pre><p>To prevent backup storage from growing indefinitely, Azure Storage Management Policies are also configured. These lifecycle rules automatically move older backups to the Cool tier and delete them after a configurable retention period. This approach keeps storage costs predictable while ensuring that backup data is automatically cleaned up according to the retention requirements defined for each backup type.</p><h4>Deploying the Azure resources</h4><p>With the remote backend configured and the Key Vault definition in place, the deployment process is identical to any other Terraform project:</p><pre>terraform init<br>terraform plan<br>terraform apply -auto-approve</pre><p>After the deployment completes, Azure Key Vault becomes available and Terraform automatically stores its state in the Blob Storage backend created earlier.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/742/1*a1Npu9VRC2JmHqsu-e4VBQ.png" /><figcaption>The resources that should be available after Terraform execution</figcaption></figure><p>By managing backup storage through Terraform, both the infrastructure and retention policies remain fully version-controlled and reproducible alongside the rest of the platform configuration.</p><h4>Creating a Service Principal for Kubernetes</h4><p>At this point, the Key Vault exists, but Kubernetes still has no way of accessing it. Since my cluster is running on-premises rather than in Azure, I can’t rely on Managed Identities. Instead, I’ll use a dedicated Azure Service Principal that the External Secrets Operator (ESO) will use to authenticate against Azure Key Vault.</p><p>The idea is simple: Kubernetes gets its own identity in Azure with only the permissions it needs. In this case, ESO only needs to read secrets from the vault, so there’s no reason to grant broader permissions.</p><p>To create the Service Principal, I ran:</p><pre>az ad sp create-for-rbac \<br>  --name &lt;KUBERNETES_SP_NAME&gt; \<br>  --skip-assignment</pre><p>The command returns a client ID, client secret, and tenant ID:</p><pre>{<br>  &quot;appId&quot;: &quot;...&quot;,<br>  &quot;displayName&quot;: &quot;&lt;KUBERNETES_SP_NAME&gt;&quot;,<br>  &quot;password&quot;: &quot;...&quot;,<br>  &quot;tenant&quot;: &quot;...&quot;<br>}</pre><p>These credentials will later be stored securely using Ansible Vault and deployed into Kubernetes as a Secret consumed by External Secrets Operator.</p><h4>Granting Access to Azure Key Vault</h4><p>Next, I retrieved the Azure Resource ID of the Key Vault:</p><pre>KV_ID=$(az keyvault show \<br>  --name &lt;KEYVAULT_NAME&gt; \<br>  --resource-group &lt;YOUR_RESOURCE_GROUP&gt;\<br>  --query id -o tsv)</pre><p>With the vault ID available, I assigned the <strong>Key Vault Secrets User</strong> role to the newly created Service Principal:</p><pre>az role assignment create \<br>  --assignee &quot;&lt;SERVICE_PRINCIPAL_ID&gt;&quot; \<br>  --role &quot;Key Vault Secrets User&quot; \<br>  --scope $KV_ID</pre><p>Importantly, it does <strong>not</strong> grant permissions to:</p><ul><li>Create new secrets</li><li>Modify existing secrets</li><li>Delete secrets</li><li>Manage Key Vault configuration</li></ul><p>As a result, even if the credentials used by Kubernetes were somehow compromised, the attacker would still be limited to reading secrets already present in the vault.</p><p>This keeps the Kubernetes integration relatively simple while maintaining a clear separation between secret consumers and secret administrators.</p><h4>Granting Administrative Access for Secret Management</h4><p>While Kubernetes only needs read access, I still need a way to create and manage secrets in Azure Key Vault myself.</p><p>To retrieve my Azure user object ID, I used:</p><pre>az ad signed-in-user show --query id -o tsv</pre><p>Then I assigned myself the Key Vault Secrets Officer role:</p><pre>az role assignment create \<br>  --assignee &lt;YOUR_USER_ID&gt; \<br>  --role &quot;Key Vault Secrets Officer&quot; \<br>  --scope $KV_ID</pre><p>This role allows me to:</p><ul><li>Create secrets</li><li>Update secret values</li><li>Delete secrets</li><li>Manage secret versions</li></ul><p>At this stage, Azure is fully prepared. The Key Vault has been provisioned, access controls have been configured, and Kubernetes has a dedicated identity that can retrieve secrets. The next step is moving back to the cluster and installing External Secrets Operator, which will use these credentials to synchronize secrets from Azure Key Vault into Kubernetes.</p><h4>Configuring External Secrets Operator with Ansible</h4><p>Now that Azure Key Vault exists and the Kubernetes Service Principal has access to read secrets from it, I can move back to the Ansible configuration.</p><p>The goal here is to install <strong>External Secrets Operator</strong> into the RKE2 cluster and configure it to use Azure Key Vault as the external secret backend. I want to keep this consistent with the rest of the platform, so ESO will also be installed from Ansible rather than manually with helm install.</p><p>First, I added the ESO-related variables to group_vars/all.yml:</p><pre># External Secrets Operator<br><br>eso_chart_version: &quot;2.7.0&quot;<br>eso_namespace: &quot;external-secrets-system&quot;<br>azure_tenant_id: &quot;xxx&quot;<br>azure_keyvault_name: &quot;xxx&quot;</pre><p>I pinned the chart version on purpose. I do not want the playbook to always install whatever happens to be the latest version at the time of execution. Version pinning makes the deployment more predictable and easier to troubleshoot later.</p><p>The namespace is also defined as a variable because I prefer keeping platform components separated from application workloads. In this case, ESO will be installed into its own namespace called external-secrets-system.</p><p>The Azure tenant ID and Key Vault name are not secrets by themselves, so I keep them in all.yml.</p><p>The actual sensitive values are stored in Ansible Vault:</p><pre>ansible-vault edit group_vars/vault.yml</pre><p>Inside the vault file, I store the sensitive values required by the following automation steps. The file is encrypted with Ansible Vault, so it can be committed to the repository safely as long as the Vault password itself is stored separately and never committed.</p><p>Depending on which integrations are enabled, this file contains credentials for External Secrets Operator, GitOps repository access, LDAP authentication, Argo CD administrator credentials, and optionally OIDC credentials for Authentik integration.</p><pre><br># Azure Key Vault authentication used by External Secrets Operator<br><br>azure_eso_client_id: &quot;...&quot;<br>azure_eso_client_secret: &quot;...&quot;<br><br># LDAP integration<br><br>ldap_bind_dn: &quot;...&quot;<br>ldap_bind_password: &quot;...&quot;<br><br># GitOps repository access (read only service account to your gitops repo)<br><br>gitlab_username: &quot;...&quot;<br>gitlab_token: &quot;...&quot;<br><br># Argo CD administrator password (bcrypt hash)<br><br>argocd_admin_password_hash: &quot;...&quot;<br><br># Optional OIDC / Authentik integration<br><br>authentik_client_id: &quot;...&quot;<br>authentik_client_secret: &quot;...&quot;</pre><p>The complete playbook looks as follows:</p><pre>- name: Install External Secrets Operator<br>  hosts: masters[0]<br>  become: true<br>  remote_user: cloud<br>  gather_facts: false<br><br>  vars_files:<br>    - ../group_vars/all.yml<br>    - ../group_vars/vault.yml<br><br>  tasks:<br><br>    ############################################################<br>    # Namespace<br>    ############################################################<br><br>    - name: Create ESO namespace<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: v1<br>          kind: Namespace<br>          metadata:<br>            name: &quot;{{ eso_namespace }}&quot;<br><br>    ############################################################<br>    # Helm repo<br>    ############################################################<br><br>    - name: Add External Secrets Helm repo<br>      kubernetes.core.helm_repository:<br>        name: external-secrets<br>        repo_url: https://charts.external-secrets.io<br><br>    ############################################################<br>    # Install ESO<br>    ############################################################<br><br>    - name: Install External Secrets Operator<br>      kubernetes.core.helm:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        name: external-secrets<br>        chart_ref: external-secrets/external-secrets<br>        chart_version: &quot;{{ eso_chart_version }}&quot;<br>        release_namespace: &quot;{{ eso_namespace }}&quot;<br>        create_namespace: true<br>        wait: true<br>        update_repo_cache: true<br>        values:<br>          installCRDs: true<br><br>    ############################################################<br>    # Wait for CRDs<br>    ############################################################<br><br>    - name: Wait for ClusterSecretStore CRD<br>      command: &gt;<br>        {{ rke2_kubectl }} get crd clustersecretstores.external-secrets.io<br>      register: crd_result<br>      retries: 30<br>      delay: 10<br>      changed_when: false<br>      until: crd_result.rc == 0<br><br>    - name: Wait for ClusterSecretStore API registration<br>      command: &gt;<br>        {{ rke2_kubectl }} api-resources<br>      register: api_resources<br>      retries: 30<br>      delay: 5<br>      changed_when: false<br>      until: &quot;&#39;clustersecretstores&#39; in api_resources.stdout&quot;<br><br>    ############################################################<br>    # Azure SP bootstrap Secret<br>    ############################################################<br><br>    - name: Create Azure authentication secret<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: v1<br>          kind: Secret<br>          metadata:<br>            name: azure-kv-auth<br>            namespace: &quot;{{ eso_namespace }}&quot;<br>          type: Opaque<br>          stringData:<br>            ClientID: &quot;{{ azure_eso_client_id }}&quot;<br>            ClientSecret: &quot;{{ azure_eso_client_secret }}&quot;<br><br>    ############################################################<br>    # ClusterSecretStore<br>    ############################################################<br><br>    - name: Create Azure Key Vault ClusterSecretStore<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: external-secrets.io/v1<br>          kind: ClusterSecretStore<br>          metadata:<br>            name: azure-kv<br>          spec:<br>            provider:<br>              azurekv:<br>                tenantId: &quot;{{ azure_tenant_id }}&quot;<br>                vaultUrl: &quot;https://{{ azure_keyvault_name }}.vault.azure.net/&quot;<br><br>                authSecretRef:<br>                  clientId:<br>                    name: azure-kv-auth<br>                    namespace: &quot;{{ eso_namespace }}&quot;<br>                    key: ClientID<br><br>                  clientSecret:<br>                    name: azure-kv-auth<br>                    namespace: &quot;{{ eso_namespace }}&quot;<br>                    key: ClientSecret<br><br>    ############################################################<br>    # Wait for ESO deployment<br>    ############################################################<br><br>    - name: Wait for ESO deployment<br>      kubernetes.core.k8s_info:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        api_version: apps/v1<br>        kind: Deployment<br>        namespace: &quot;{{ eso_namespace }}&quot;<br>        name: external-secrets<br>      register: eso_status<br>      retries: 30<br>      delay: 10<br>      until: &gt;<br>        eso_status.resources | length &gt; 0 and<br>        eso_status.resources[0].status.availableReplicas | default(0) | int &gt; 0<br><br>    ############################################################<br>    # Verify ClusterSecretStore<br>    ############################################################<br><br>    - name: Verify ClusterSecretStore<br>      command: &gt;<br>        {{ rke2_kubectl }} get clustersecretstore azure-kv -o yaml<br>      register: css_verify<br>      changed_when: false<br><br>    - name: Display ClusterSecretStore<br>      debug:<br>        var: css_verify.stdout<br><br>    ############################################################<br>    # Show ESO version<br>    ############################################################<br><br>    - name: Get ESO deployment<br>      kubernetes.core.k8s_info:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        api_version: apps/v1<br>        kind: Deployment<br>        namespace: &quot;{{ eso_namespace }}&quot;<br>        name: external-secrets<br>      register: eso_deployment<br><br>    - name: Display ESO image version<br>      debug:<br>        msg: &gt;<br>          ESO image:<br>          {{<br>            eso_deployment.resources[0].spec.template.spec.containers[0].image<br>          }}</pre><p>The playbook installs External Secrets Operator, creates a dedicated namespace, deploys the required CRDs, creates a bootstrap Kubernetes Secret containing the Azure credentials, and configures a cluster-wide ClusterSecretStore pointing at Azure Key Vault.</p><p>One thing worth mentioning is the bootstrap secret. Since ESO needs credentials before it can access Azure Key Vault, the Service Principal credentials must initially exist as a regular Kubernetes Secret. This is the only Azure-related secret that needs to be created manually inside the cluster. Everything else can later be synchronized directly from Azure Key Vault.</p><p>After running the playbook, External Secrets Operator is installed and connected to Azure Key Vault. Kubernetes can now authenticate to Azure and retrieve secrets through the azure-kv ClusterSecretStore, which becomes the central integration point for all future workloads that require secret management.</p><h4>Installing Argo CD with Azure Key Vault Integration</h4><p>With Azure Key Vault and External Secrets Operator already deployed, the next step is installing Argo CD and bootstrapping the GitOps workflow.</p><p>To keep the platform fully reproducible, I install Argo CD using a dedicated Ansible playbook 15-install-argocd.yml.</p><p>Instead of manually configuring Argo CD through the UI, the playbook performs the entire deployment automatically.</p><pre>- name: Install Argo CD with ESO-backed secrets, LDAP, Reloader and GitOps bootstrap<br>  hosts: masters[0]<br>  become: true<br>  gather_facts: false<br><br>  vars_files:<br>    - ../group_vars/all.yml<br><br>  vars:<br>    argocd_manifest_url: &gt;-<br>      https://raw.githubusercontent.com/argoproj/argo-cd/{{ argocd_version }}/manifests/install.yaml<br><br>    # External Secrets<br>    eso_cluster_secret_store_name: azure-kv<br><br>    # Azure Key Vault secret names<br>    akv_argocd_admin_password_key: argocd-admin-password-bcrypthash<br><br>    akv_gitlab_username_key: argocd-gitlab-username<br>    akv_gitlab_token_key: argocd-gitlab-token<br><br>    akv_ldap_bind_dn_key: kubernetesldapacc-ldap-bind-dn<br>    akv_ldap_bind_password_key: kubernetesldapacc-ldap-bind-password<br><br>  tasks:<br><br>    ###########################################################################<br>    # Pre-flight checks<br>    ###########################################################################<br><br>    - name: Check ExternalSecret CRD exists<br>      command: kubectl get crd externalsecrets.external-secrets.io<br>      changed_when: false<br><br>    - name: Check ClusterSecretStore exists<br>      command: &gt;<br>        kubectl get clustersecretstore.external-secrets.io<br>        {{ eso_cluster_secret_store_name }}<br>      changed_when: false<br><br>    ###########################################################################<br>    # Namespace<br>    ###########################################################################<br><br>    - name: Ensure Argo CD namespace exists<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: v1<br>          kind: Namespace<br>          metadata:<br>            name: &quot;{{ argocd_namespace }}&quot;<br><br>    ###########################################################################<br>    # Install Argo CD upstream manifests<br>    ###########################################################################<br><br>    - name: Apply Argo CD upstream manifests<br>      command: &gt;<br>        kubectl apply<br>        --server-side<br>        --force-conflicts<br>        -n {{ argocd_namespace }}<br>        -f {{ argocd_manifest_url }}<br><br>    - name: Wait for Argo CD deployments<br>      command: &gt;<br>        kubectl -n {{ argocd_namespace }}<br>        wait deployment --all<br>        --for=condition=Available<br>        --timeout=300s<br>      changed_when: false<br><br>    ###########################################################################<br>    # Argo CD server parameters<br>    ###########################################################################<br><br>    - name: Configure Argo CD server insecure mode<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: v1<br>          kind: ConfigMap<br>          metadata:<br>            name: argocd-cmd-params-cm<br>            namespace: &quot;{{ argocd_namespace }}&quot;<br>          data:<br>            server.insecure: &quot;true&quot;<br><br>    ###########################################################################<br>    # Argo CD service - ClusterIP only<br>    ###########################################################################<br><br>    - name: Configure argocd-server service as ClusterIP<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: v1<br>          kind: Service<br>          metadata:<br>            name: argocd-server<br>            namespace: &quot;{{ argocd_namespace }}&quot;<br>          spec:<br>            type: ClusterIP<br>            selector:<br>              app.kubernetes.io/name: argocd-server<br>            ports:<br>              - name: http<br>                port: 80<br>                targetPort: 8080<br><br>    ###########################################################################<br>    # ExternalSecret: argocd-secret<br>    #<br>    # This merges values into the existing upstream argocd-secret.<br>    # Secrets come only from Azure Key Vault.<br>    ###########################################################################<br><br>    - name: Create ExternalSecret for argocd-secret<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: external-secrets.io/v1<br>          kind: ExternalSecret<br>          metadata:<br>            name: argocd-secret<br>            namespace: &quot;{{ argocd_namespace }}&quot;<br>          spec:<br>            refreshInterval: 1m<br><br>            secretStoreRef:<br>              kind: ClusterSecretStore<br>              name: &quot;{{ eso_cluster_secret_store_name }}&quot;<br><br>            target:<br>              name: argocd-secret<br>              creationPolicy: Merge<br><br>            data:<br>              - secretKey: dex.ldap.bindDN<br>                remoteRef:<br>                  key: &quot;{{ akv_ldap_bind_dn_key }}&quot;<br><br>              - secretKey: dex.ldap.bindPW<br>                remoteRef:<br>                  key: &quot;{{ akv_ldap_bind_password_key }}&quot;<br><br>              - secretKey: admin.password<br>                remoteRef:<br>                  key: &quot;{{ akv_argocd_admin_password_key }}&quot;<br><br><br>    ###########################################################################<br>    # ExternalSecret: GitOps repository secret<br>    #<br>    # Argo CD repository credential secret.<br>    # Username/password come only from Azure Key Vault.<br>    ###########################################################################<br><br>    - name: Create ExternalSecret for GitOps repository secret<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: external-secrets.io/v1<br>          kind: ExternalSecret<br>          metadata:<br>            name: gitops-repo<br>            namespace: &quot;{{ argocd_namespace }}&quot;<br>          spec:<br>            refreshInterval: 1m<br><br>            secretStoreRef:<br>              kind: ClusterSecretStore<br>              name: &quot;{{ eso_cluster_secret_store_name }}&quot;<br><br>            target:<br>              name: gitops-repo<br>              creationPolicy: Owner<br><br>              template:<br>                engineVersion: v2<br><br>                metadata:<br>                  labels:<br>                    argocd.argoproj.io/secret-type: repository<br><br>                data:<br>                  type: git<br>                  url: &quot;{{ gitops_repo_url }}&quot;<br>                  username: &#39;{{ &quot;{{ .username }}&quot; }}&#39;<br>                  password: &#39;{{ &quot;{{ .password }}&quot; }}&#39;<br><br>            data:<br>              - secretKey: username<br>                remoteRef:<br>                  key: &quot;{{ akv_gitlab_username_key }}&quot;<br><br>              - secretKey: password<br>                remoteRef:<br>                  key: &quot;{{ akv_gitlab_token_key }}&quot;<br><br>    ###########################################################################<br>    # Wait for ESO synchronization<br>    ###########################################################################<br><br>    - name: Wait for ExternalSecret argocd-secret to become Ready<br>      command: &gt;<br>        kubectl -n {{ argocd_namespace }}<br>        wait externalsecret.external-secrets.io/argocd-secret<br>        --for=condition=Ready=True<br>        --timeout=300s<br>      changed_when: false<br><br>    - name: Wait for ExternalSecret gitops-repo to become Ready<br>      command: &gt;<br>        kubectl -n {{ argocd_namespace }}<br>        wait externalsecret.external-secrets.io/gitops-repo<br>        --for=condition=Ready=True<br>        --timeout=300s<br>      changed_when: false<br><br>    - name: Verify argocd-secret exists<br>      command: kubectl -n {{ argocd_namespace }} get secret argocd-secret<br>      changed_when: false<br><br>    - name: Verify gitops-repo secret exists<br>      command: kubectl -n {{ argocd_namespace }} get secret gitops-repo<br>      changed_when: false<br><br>    ###########################################################################<br>    # Argo CD configuration with LDAP / Dex<br>    ###########################################################################<br><br>    - name: Configure Argo CD URL and Dex LDAP connector<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: v1<br>          kind: ConfigMap<br>          metadata:<br>            name: argocd-cm<br>            namespace: &quot;{{ argocd_namespace }}&quot;<br>          data:<br>            url: &quot;https://{{ argocd_hostname }}&quot;<br>            admin.enabled: &quot;true&quot;<br>            sso.autoRedirect: &quot;false&quot;<br><br>            dex.config: |<br>              logger:<br>                level: info<br><br>              connectors:<br>              - type: ldap<br>                id: openldap<br>                name: OpenLDAP<br><br>                config:<br>                  host: &quot;{{ ldap.host }}&quot;<br>                  insecureNoSSL: true<br>                  insecureSkipVerify: true<br>                  startTLS: false<br><br>                  bindDN: &quot;$dex.ldap.bindDN&quot;<br>                  bindPW: &quot;$dex.ldap.bindPW&quot;<br><br>                  usernamePrompt: Username<br><br>                  userSearch:<br>                    baseDN: &quot;{{ ldap.user_base_dn }}&quot;<br>                    filter: &quot;{{ ldap.user_filter }}&quot;<br>                    username: &quot;{{ ldap.user_attr }}&quot;<br>                    idAttr: &quot;{{ ldap.user_attr }}&quot;<br>                    emailAttr: &quot;{{ ldap.email_attr }}&quot;<br>                    nameAttr: &quot;{{ ldap.name_attr }}&quot;<br><br>                  groupSearch:<br>                    baseDN: &quot;{{ ldap.group_base_dn }}&quot;<br>                    filter: &quot;{{ ldap.group_filter }}&quot;<br><br>                    userMatchers:<br>                    - userAttr: DN<br>                      groupAttr: &quot;{{ ldap.group_attr }}&quot;<br><br>                    nameAttr: cn<br><br>    ###########################################################################<br>    # Argo CD RBAC<br>    ###########################################################################<br><br>    - name: Configure Argo CD RBAC<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: v1<br>          kind: ConfigMap<br>          metadata:<br>            name: argocd-rbac-cm<br>            namespace: &quot;{{ argocd_namespace }}&quot;<br>          data:<br>            policy.default: role:readonly<br>            scopes: &#39;[groups,email]&#39;<br>            policy.csv: |<br>              g, {{ argocd_admin_group }}, role:admin<br><br>    ###########################################################################<br>    # Reloader annotations<br>    #<br>    # These annotations are placed on Pod templates.<br>    # Patching Pod templates also triggers the initial rollout after config/secret changes.<br>    ###########################################################################<br><br>    - name: Add Reloader annotations to Argo CD deployments<br>      command: &gt;<br>        kubectl -n {{ argocd_namespace }}<br>        patch deployment {{ item }}<br>        --type merge<br>        -p &#39;{&quot;spec&quot;:{&quot;template&quot;:{&quot;metadata&quot;:{&quot;annotations&quot;:{&quot;reloader.stakater.com/auto&quot;:&quot;true&quot;,&quot;secret.reloader.stakater.com/reload&quot;:&quot;argocd-secret,gitops-repo&quot;,&quot;configmap.reloader.stakater.com/reload&quot;:&quot;argocd-cm,argocd-rbac-cm,argocd-cmd-params-cm&quot;}}}}}&#39;<br>      loop:<br>        - argocd-server<br>        - argocd-dex-server<br>        - argocd-repo-server<br>        - argocd-applicationset-controller<br>        - argocd-notifications-controller<br>      register: patch_deployments<br>      failed_when: false<br>      changed_when: &quot;&#39;patched&#39; in patch_deployments.stdout or &#39;configured&#39; in patch_deployments.stdout&quot;<br><br>    - name: Add Reloader annotations to Argo CD application controller StatefulSet<br>      command: &gt;<br>        kubectl -n {{ argocd_namespace }}<br>        patch statefulset argocd-application-controller<br>        --type merge<br>        -p &#39;{&quot;spec&quot;:{&quot;template&quot;:{&quot;metadata&quot;:{&quot;annotations&quot;:{&quot;reloader.stakater.com/auto&quot;:&quot;true&quot;,&quot;secret.reloader.stakater.com/reload&quot;:&quot;argocd-secret,gitops-repo&quot;,&quot;configmap.reloader.stakater.com/reload&quot;:&quot;argocd-cm,argocd-rbac-cm,argocd-cmd-params-cm&quot;}}}}}&#39;<br>      register: patch_application_controller<br>      failed_when: false<br>      changed_when: &quot;&#39;patched&#39; in patch_application_controller.stdout or &#39;configured&#39; in patch_application_controller.stdout&quot;<br><br>    ###########################################################################<br>    # Wait for Argo CD after rollout triggered by annotation/config changes<br>    ###########################################################################<br><br>    - name: Wait for argocd-server rollout<br>      command: &gt;<br>        kubectl -n {{ argocd_namespace }}<br>        rollout status deployment/argocd-server<br>        --timeout=300s<br>      changed_when: false<br><br>    - name: Wait for argocd-dex-server rollout<br>      command: &gt;<br>        kubectl -n {{ argocd_namespace }}<br>        rollout status deployment/argocd-dex-server<br>        --timeout=300s<br>      changed_when: false<br><br>    - name: Wait for argocd-repo-server rollout<br>      command: &gt;<br>        kubectl -n {{ argocd_namespace }}<br>        rollout status deployment/argocd-repo-server<br>        --timeout=300s<br>      changed_when: false<br><br>    ###########################################################################<br>    # Bootstrap GitOps<br>    #<br>    # This uses gitops-repo secret created by ESO.<br>    ###########################################################################<br><br>    - name: Bootstrap Argo CD from GitOps repository<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: argoproj.io/v1alpha1<br>          kind: Application<br>          metadata:<br>            name: gitops-bootstrap<br>            namespace: &quot;{{ argocd_namespace }}&quot;<br>          spec:<br>            project: default<br><br>            source:<br>              repoURL: &quot;{{ gitops_repo_url }}&quot;<br>              targetRevision: &quot;{{ gitops_repo_revision | default(&#39;main&#39;) }}&quot;<br>              path: 0-argocd/0-bootstrap<br>              directory:<br>                recurse: true<br><br>            destination:<br>              server: https://kubernetes.default.svc<br>              namespace: &quot;{{ argocd_namespace }}&quot;<br><br>            syncPolicy:<br>              automated:<br>                prune: true<br>                selfHeal: true</pre><p>The playbook:</p><ul><li>Creates the Argo CD namespace</li><li>Installs Argo CD from upstream manifests</li><li>Configures Argo CD server settings</li><li>Integrates Argo CD with Azure Key Vault through External Secrets Operator</li><li>Creates the Git repository secret used by Argo CD</li><li>Configures LDAP authentication through Dex (omit this step if you don’t want to authenticate with LDAP)</li><li>Configures RBAC permissions</li><li>Enables automatic reloads using Reloader</li><li>Bootstraps GitOps applications from Gitlab</li></ul><p>Note: Some older versions of ArgoCD may have a bug with LDAP password. If it contains $ sign then the LDAP integration will return an error. See this -&gt; <a href="https://github.com/argoproj/argo-cd/issues/27803">https://github.com/argoproj/argo-cd/issues/27803</a></p><p>To execute this script use:</p><pre>ansible-playbook playbooks/15-install-argocd.yml \<br>  --ask-vault-pass</pre><p>But you first have to fill in the variables and the vault.yml secrets that are used for this configuration.</p><h4>Optional: Integrating Argo CD with Authentik via OIDC</h4><p>While the current deployment uses either the built-in Argo CD administrator account or LDAP authentication through Dex, the platform is designed to support centralized authentication using Authentik in the future.</p><p>This optional playbook configures Argo CD to use Authentik as an external OpenID Connect (OIDC) provider. Client credentials are retrieved securely from Azure Key Vault through External Secrets Operator, and Dex is configured to trust Authentik for authentication.</p><p>The playbook:</p><ul><li>Creates additional External Secret mappings for Authentik OIDC credentials</li><li>Retrieves the OIDC Client ID and Client Secret from Azure Key Vault</li><li>Configures Dex to use Authentik as an identity provider</li><li>Enables group synchronization using OIDC claims</li><li>Maps Authentik groups directly to Argo CD roles through RBAC</li><li>Restarts Dex and Argo CD to apply the new authentication configuration</li></ul><p>At the time of writing, I continue using local administrator accounts and LDAP authentication while building the platform. Once Authentik itself is deployed and managed through Argo CD, I plan to switch all platform services to centralized OIDC authentication using Authentik as the single identity provider, but if you want to integrate with OIDC, use the below playbook.</p><pre>- name: Install Argo CD with ESO-backed secrets, LDAP, Reloader and GitOps bootstrap<br>  hosts: masters[0]<br>  become: true<br>  gather_facts: false<br><br>  vars_files:<br>    - ../group_vars/all.yml<br><br>  vars:<br>    argocd_manifest_url: &gt;-<br>      https://raw.githubusercontent.com/argoproj/argo-cd/{{ argocd_version }}/manifests/install.yaml<br><br>    # External Secrets<br>    eso_cluster_secret_store_name: azure-kv<br><br>    # Azure Key Vault secret names<br>    akv_argocd_admin_password_key: argocd-admin-password-bcrypthash<br><br>    akv_gitlab_username_key: argocd-gitlab-username<br>    akv_gitlab_token_key: argocd-gitlab-token<br><br>    akv_ldap_bind_dn_key: kubernetesldapacc-ldap-bind-dn<br>    akv_ldap_bind_password_key: kubernetesldapacc-ldap-bind-password<br>    akv_authentik_client_id_key: argocd-authentik-client-id<br>    akv_authentik_client_secret_key: argocd-authentik-client-secret<br><br>  tasks:<br><br>    ###########################################################################<br>    # Pre-flight checks<br>    ###########################################################################<br><br>    - name: Check ExternalSecret CRD exists<br>      command: kubectl get crd externalsecrets.external-secrets.io<br>      changed_when: false<br><br>    - name: Check ClusterSecretStore exists<br>      command: &gt;<br>        kubectl get clustersecretstore.external-secrets.io<br>        {{ eso_cluster_secret_store_name }}<br>      changed_when: false<br><br>    ###########################################################################<br>    # Namespace<br>    ###########################################################################<br><br>    - name: Ensure Argo CD namespace exists<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: v1<br>          kind: Namespace<br>          metadata:<br>            name: &quot;{{ argocd_namespace }}&quot;<br>    ###########################################################################<br>    # ExternalSecret: argocd-secret<br>    #<br>    # This merges values into the existing upstream argocd-secret.<br>    # Secrets come only from Azure Key Vault.<br>    ###########################################################################<br><br>    - name: Create ExternalSecret for argocd-secret<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: external-secrets.io/v1<br>          kind: ExternalSecret<br>          metadata:<br>            name: argocd-secret<br>            namespace: &quot;{{ argocd_namespace }}&quot;<br>          spec:<br>            refreshInterval: 5m<br><br>            secretStoreRef:<br>              kind: ClusterSecretStore<br>              name: &quot;{{ eso_cluster_secret_store_name }}&quot;<br><br>            target:<br>              name: argocd-secret<br>              creationPolicy: Merge<br><br>            data:<br>              - secretKey: dex.ldap.bindDN<br>                remoteRef:<br>                  key: &quot;{{ akv_ldap_bind_dn_key }}&quot;<br><br>              - secretKey: dex.ldap.bindPW<br>                remoteRef:<br>                  key: &quot;{{ akv_ldap_bind_password_key }}&quot;<br><br>              - secretKey: admin.password<br>                remoteRef:<br>                  key: &quot;{{ akv_argocd_admin_password_key }}&quot;<br><br>              - secretKey: dex.authentik.clientId<br>                remoteRef:<br>                  key: &quot;{{ akv_authentik_client_id_key }}&quot;<br><br>              - secretKey: dex.authentik.clientSecret<br>                remoteRef:<br>                  key: &quot;{{ akv_authentik_client_secret_key }}&quot;<br><br>    - name: Configure Argo CD URL and Dex LDAP connector<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: v1<br>          kind: ConfigMap<br>          metadata:<br>            name: argocd-cm<br>            namespace: &quot;{{ argocd_namespace }}&quot;<br>          data:<br>            url: &quot;https://{{ argocd_hostname }}&quot;<br>            admin.enabled: &quot;true&quot;<br>            sso.autoRedirect: &quot;false&quot;<br>            dex.config: |<br>              connectors:<br>              - type: oidc<br><br>                id: authentik<br>                name: Authentik<br><br>                config:<br>                  issuer: https://auth.&lt;your_domain&gt;.pl/application/o/argocd-dex/<br><br>                  clientID: $dex.authentik.clientId<br>                  clientSecret: $dex.authentik.clientSecret<br><br>                  insecureEnableGroups: true<br><br>                  scopes:<br>                    - openid<br>                    - profile<br>                    - email<br>                    - groups<br><br>    - name: Configure Argo CD RBAC<br>      kubernetes.core.k8s:<br>        kubeconfig: &quot;{{ rke2_kubeconfig }}&quot;<br>        state: present<br>        definition:<br>          apiVersion: v1<br>          kind: ConfigMap<br>          metadata:<br>            name: argocd-rbac-cm<br>            namespace: &quot;{{ argocd_namespace }}&quot;<br>          data:<br>            policy.default: role:readonly<br><br>            scopes: &#39;[groups,email]&#39;<br><br><br>            policy.csv: |<br><br>              # ==========================================<br>              # Global<br>              # ==========================================<br>              g, argocd-root-admins, role:admin<br><br>              g, argocd-readonly, role:readonly<br><br>              # ==========================================<br>              # Applications<br>              # ==========================================<br><br>              g, argocd-apps-admins, role:apps-admin<br><br>              p, role:apps-admin, applications, get, apps/*, allow<br>              p, role:apps-admin, applications, sync, apps/*, allow<br>              p, role:apps-admin, applications, action/*, apps/*, allow<br>              p, role:apps-admin, logs, get, apps/*, allow<br><br>              # ==========================================<br>              # Platform<br>              # ==========================================<br><br>              g, argocd-platform-admins, role:platform-admin<br><br>              p, role:platform-admin, applications, get, platform/*, allow<br>              p, role:platform-admin, applications, sync, platform/*, allow<br>              p, role:platform-admin, applications, action/*, platform/*, allow<br>              p, role:platform-admin, logs, get, platform/*, allow<br><br>              # ==========================================<br>              # Infrastructure<br>              # ==========================================<br><br>              g, argocd-infra-admins, role:infra-admin<br><br>              p, role:infra-admin, applications, get, infra/*, allow<br>              p, role:infra-admin, applications, sync, infra/*, allow<br>              p, role:infra-admin, applications, action/*, infra/*, allow<br>              p, role:infra-admin, logs, get, infra/*, allow<br><br>              # ==========================================<br>              # Monitoring<br>              # ==========================================<br><br>              g, argocd-monitoring-admins, role:monitoring-admin<br><br>              p, role:monitoring-admin, applications, get, monitoring/*, allow<br>              p, role:monitoring-admin, applications, sync, monitoring/*, allow<br>              p, role:monitoring-admin, applications, action/*, monitoring/*, allow<br>              p, role:monitoring-admin, logs, get, monitoring/*, allow<br><br>              # ==========================================<br>              # Learning<br>              # ==========================================<br><br>              g, argocd-learning-admins, role:learning-admin<br><br>              p, role:learning-admin, applications, get, learning/*, allow<br>              p, role:learning-admin, applications, sync, learning/*, allow<br>              p, role:learning-admin, applications, action/*, learning/*, allow<br>              p, role:learning-admin, logs, get, learning/*, allow<br><br><br><br>    ###########################################################################<br>    # Restart ArgoCD after Dex/Auth changes<br>    ###########################################################################<br><br>    - name: Restart argocd-dex-server<br>      command: &gt;<br>        kubectl -n {{ argocd_namespace }}<br>        rollout restart deployment/argocd-dex-server<br>      changed_when: true<br><br>    - name: Wait for argocd-dex-server rollout<br>      command: &gt;<br>        kubectl -n {{ argocd_namespace }}<br>        rollout status deployment/argocd-dex-server<br>        --timeout=300s<br>      changed_when: false<br><br>    - name: Restart argocd-server<br>      command: &gt;<br>        kubectl -n {{ argocd_namespace }}<br>        rollout restart deployment/argocd-server<br>      changed_when: true<br><br>    - name: Wait for argocd-server rollout<br>      command: &gt;<br>        kubectl -n {{ argocd_namespace }}<br>        rollout status deployment/argocd-server<br>        --timeout=300s<br>      changed_when: false</pre><p>If you execute this script you should be able to log in with your OIDC provider (as long as you configured the proper secrets and links on the provider side). I’ll show the details of this in the next article.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/848/1*SPJ-v62bxyZUBnTIAa954A.png" /><figcaption>The final login view with Authentik (if you decided to execute the optional OIDC integration step)</figcaption></figure><p>Currently my architecture looks like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*dI5qVHpuqZONNtGeYJ-BGg.png" /><figcaption>Current architecture with GitLab integrated to the ArgoCD and Kuberenetes integrated to Azure Key Vault</figcaption></figure><p>At this point, the platform has a solid foundation: a highly available Kubernetes cluster, centralized secret management with Azure Key Vault, automated secret synchronization through External Secrets Operator, and a fully bootstrapped GitOps workflow powered by Argo CD.</p><p>In the next article, I’ll continue building the platform by deploying Authentik as the central identity provider and integrating it with the rest of the environment. I’ll also start deploying additional workloads and platform services through GitOps to move closer to a fully self-managed Kubernetes platform.</p><p>Stay tuned. 🚀</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=2c900c9755a3" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Building a Kubernetes Platform from Scratch (Part 1: Design + RKE2 deployment with Ansible)”]]></title>
            <link>https://medium.com/@aleksander.chotecki/building-a-kubernetes-platform-from-scratch-part-1-design-rke2-deployment-with-ansible-dd85c9aa244d?source=rss-8277db1f513f------2</link>
            <guid isPermaLink="false">https://medium.com/p/dd85c9aa244d</guid>
            <category><![CDATA[devops]]></category>
            <category><![CDATA[ansible]]></category>
            <category><![CDATA[rke2]]></category>
            <category><![CDATA[kube-vip]]></category>
            <category><![CDATA[kubernetes]]></category>
            <dc:creator><![CDATA[Aleksander Chotecki]]></dc:creator>
            <pubDate>Sun, 21 Jun 2026 22:21:10 GMT</pubDate>
            <atom:updated>2026-06-21T22:21:10.250Z</atom:updated>
            <content:encoded><![CDATA[<p>Most Kubernetes tutorials stop at “the cluster is running” or focus on a single component in isolation. In reality, that’s just the beginning. In this series, I’ll walk through building a complete Kubernetes platform from scratch — starting at bare metal and gradually evolving it into a fully operational stack.</p><h3>What are we building?</h3><p>Here’s what the final platform will include:</p><ul><li><strong>High‑availability Kubernetes</strong> (RKE2 + kube‑vip)</li><li><strong>Storage</strong> (Longhorn + Velero for cluster state backups)</li><li><strong>Networking</strong> (Traefik + MetalLB + Cloudflare integration)</li><li><strong>Identity &amp; access</strong> (Keycloak + LDAP)</li><li><strong>Observability</strong> (Prometheus, Grafana, Loki)</li></ul><p>The goal is simple — to <strong>build something that feels like a real platform, not just a lab.</strong></p><h3>Prerequisites</h3><p>To follow along, you’ll need:</p><ul><li>At least <strong>2 bare‑metal servers</strong><br> <em>(3 would be ideal — I’ll explain why later, but I’m working with 2)</em></li><li>A <strong>Proxmox hypervisor</strong> installed on top of them, with disks configured for persistent storage</li><li>Basic network connectivity, in my case <strong>exposed through OPNsense </strong>(or any equivalent edge router)<strong> with OpenVPN</strong></li><li>A <strong>domain name</strong> managed in a DNS provider (I am using Cloudflare in this case)</li><li>Access to a <strong>public CA-compatible environment</strong> (for Let’s Encrypt auth-challenges to work)</li><li>GitLab/GitHub account for the ArgoCD workloads</li><li><strong>Ansible</strong> and <strong>Git</strong> available on your Linux/Windows + WSL workstation</li></ul><p>This series assumes you’re comfortable with basic Linux administration and Kubernetes concepts, but everything else will be explained as we go.</p><p>All the Ansible code used for this setup can be found <a href="https://github.com/heyimjustalex/medium/tree/main/Article_1">here</a>.</p><h3>Architecture</h3><p>At the bottom of the stack, everything starts with two physical machines. These are just regular bare-metal servers running Proxmox. There’s nothing particularly fancy here — no cloud, no managed services — just two hosts that provide the foundation for everything else.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*OmGcWRXuzRYQK_-OzP-LeA.png" /><figcaption>Figure 1 — Stack diagram for the whole setup</figcaption></figure><p>On top of Proxmox, virtual machines are created to form the Kubernetes cluster. The setup includes three control-plane nodes and multiple worker nodes. The control-plane nodes are responsible for managing the cluster, while the worker nodes run the actual workloads. Even though there are three masters, they are spread across only two physical hosts, which is not perfect from a HA perspective.</p><p>All of these VMs are then joined into a Kubernetes cluster powered by RKE2. This layer acts as the orchestration engine — the “brain” of the whole system. It manages scheduling, networking, and the lifecycle of workloads. From the outside, this is the part people usually think of as “Kubernetes,” but in reality, it’s sitting on top of several other layers.</p><p>Above that, you have the actual Kubernetes objects — the things that make the cluster useful. In this diagram, that’s represented by simple pods running containers (for example, small Alpine containers). These are just placeholders, but they represent what the cluster is really meant to do: run application</p><p>To sum this up:</p><ul><li>The <strong>physical layer</strong> provides compute resources.</li><li>The <strong>hypervisor (Proxmox)</strong> abstracts those resources into virtual machines.</li><li>The <strong>VM layer</strong> defines your cluster topology — masters and workers.</li><li>The <strong>Kubernetes layer (RKE2)</strong> turns those machines into a functioning cluster.</li><li>And finally, the <strong>Kubernetes objects</strong> are where actual workloads live.</li></ul><p>Let’s have a look at another diagram I’ve prepared.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*EUGjmrIFZyMGw6mmg07F8g.png" /><figcaption>Figure 2 — Architecture diagram for the cluster</figcaption></figure><p>Traffic enters the network through <strong>OPNsense</strong>, which acts as the edge router, and then gets forwarded into the internal network over the <strong>Proxmox bridges</strong>. From there, it reaches the Kubernetes cluster running on top of virtual machines.</p><p>On the control‑plane side, each master node runs the core components as static pods, along with a few supporting pieces deployed as DaemonSets. These are not vanilla Kubernetes installs, but come bundled and managed by <a href="https://docs.rke2.io/"><strong>RKE2</strong></a>, which simplifies how the cluster is bootstrapped and maintained.</p><p>Worker nodes are much simpler — they only run the data plane components (like networking) and the actual application workloads.</p><p>Overall, it’s the same setup as before, just showing more clearly how traffic flows through OPNsense into the cluster and how responsibilities are split between control plane and workers.</p><p>The Kubernetes cluster will be integrated with other services like CloudFlare for domain management, Let’s Encrypt for certificate rotation and an LDAP server that provides identity services. Let’s have a look at a diagram that shows these mechanisms from a different angle, although deploying most of these services will be covered in the next articles in this series</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*gl16hvaUFcEJNVl6821ujA.png" /><figcaption>Figure 3 — Architecture diagram of the whole infrastructure with LDAP sever, Cloudflare and Let’s Encrypt connection to cert-manager that provisions certificates for the Traefik load balancer.</figcaption></figure><p>Traffic from the internet first hits <strong>OPNsense</strong>, which acts as the entry point and routes it into the internal network through Proxmox bridges. Inside the cluster, <strong>MetalLB</strong> assigns an IP to the Traefik service, making it reachable without relying on any cloud load balancer.</p><p>On top of that, TLS is fully automated. <strong>cert-manager</strong> requests certificates from Let’s Encrypt and uses <strong>Cloudflare DNS</strong> to prove domain ownership via DNS‑01 challenges. Once issued, certificates are stored inside the cluster and automatically picked up by Traefik.</p><p>Finally, there’s also an identity layer emerging in the background. A dedicated <strong>LDAP server</strong> provides a central source of identity for services that need authentication. This will later tie into tools like Keycloak/Authentik and other workloads, so authentication is handled consistently across the platform.</p><p>Overall, the setup keeps responsibilities clean:</p><blockquote><em>OPNsense handles entry, MetalLB handles IPs, cert-manager handles certificates, and LDAP handles identity.</em></blockquote><h3>Design assumptions and trade-offs (and why they matter)</h3><h4>Using only 2 bare metals instead of 3</h4><p>This setup runs on two physical machines, even though there are three control-plane nodes. In a perfect world, each master would sit on a separate host and each host in a separate zone of the provider to fully isolate failure domains. Here, I don’t have such capabilities.</p><p>In practice, this means the level of high availability depends on which of the bare metal hosts fails. If the host running two control-plane nodes <em>(in my case this is proxmox-node0)</em> goes down, the cluster loses quorum, since etcd requires a majority (n/2 + 1) to operate. At that point, the control plane becomes unavailable.</p><p>For this environment this trade-off is acceptable, but it’s important to be aware that:</p><blockquote>multiple masters do not guarantee HA if they share the same underlying hardware.</blockquote><h3>Local drives instead of iSCSI</h3><p>Storage is backed by local disks on each node, rather than a shared system like iSCSI or NFS. Disks are configured in RAID1 at the hypervisor level.</p><p>This is primarily a cost and simplicity trade-off:</p><ul><li>no external storage dependencies</li><li>no network storage latency</li><li>easier to manage in a small setup</li></ul><blockquote>The downside is tighter coupling between workloads and nodes, which will be later handled at the Kubernetes layer using Longhorn.</blockquote><h3>RKE2 instead of vanilla Kubernetes</h3><p>The cluster is not built using kubeadm or a “from scratch” installation.</p><p>Instead, it uses <strong>RKE2</strong>, which bundles and manages core components internally. This reduces the amount of manual setup required and provides a consistent baseline, especially around security defaults and component lifecycle.</p><p>The trade-off is less visibility and control over individual components compared to a fully manual setup — but for this kind of platform build, it’s a pragmatic choice.</p><h3>Separate identity layer (LDAP-first approach)</h3><p>An external LDAP instance is used for providing the user’s identity. The LDAP server will be later integrated with Keycloak/Authentik so everything shares the same source of truth for users and access control.</p><h3>No “production illusions” — but close enough when done right</h3><p>This setup isn’t meant to pretend to be production-ready, but it’s also not far from it.</p><p>With a few adjustments, it could absolutely run production workloads. The two biggest improvements would be:</p><ul><li>distributing control-plane nodes across <strong>three independent physical hosts or availability zones</strong></li><li>implementing <strong>proper backup strategy</strong> (which we will do in the next articles)</li></ul><p>Right now, the main limitation is the lack of physical separation between control-plane nodes. Fix that, and the cluster becomes much more resilient.</p><blockquote>At that point, this stops being “just a lab” and starts looking a lot like a real production platform — just self‑managed instead of cloud‑managed.</blockquote><h3>Kubernetes Cluster Inventory</h3><p>Let’s start by defining the below table with the names, IPs and the node parameters like vCPU, RAM and storage. We will also reserve IPs for some of the services. If you want to fill in your own table or copy from mine download file from <a href="https://github.com/heyimjustalex/medium/blob/main/Article_1/diagrams/ip-table.html">here</a>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5mvx-FkytnCxPnTq39v6RQ.png" /><figcaption>Figure 4 — The inventory we will use for the setup; download link for the table can be found <a href="https://github.com/heyimjustalex/medium/blob/main/Article_1/diagrams/ip-table.htmlhttps://github.com/heyimjustalex/medium/blob/main/Article_1/diagrams/ip-table.html">here</a>.</figcaption></figure><h3>Creating Proxmox VMs</h3><p>Let’s start by opening the Proxmox hypervisor and defining the templates that will be used for creating k8s nodes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*O3UzWJV1DouIk_M7Rx4K1g.png" /><figcaption>Figure 5 — Proxmox admin panel with visible VMs, network bridges and the host subnet.</figcaption></figure><p>We’ll start with creating templates on each of the bare metals for both types of nodes (master and worker). For the node system I’ll use <em>ubuntu-26–04</em>, which needs to be downloaded within the Proxmox admin panel. Let’s click Create VM button in the top right corner and create the first template with parameters consistent with <em>Figure 6 </em>below.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Y0V4aN8MZnB2R-pQ63PjyA.png" /><figcaption>Figure 6 — Template creation process</figcaption></figure><p>Then, click PPM on the created object and convert it to the template.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*xhuBoi0yZsTOxrQIkau7nA.png" /><figcaption>Figure 7 — Converting the created VM to template</figcaption></figure><p>Then we need to do it 3 more times (for both master and worker nodes, for each of the Proxmox machines). In the end we should see something like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lHh282-ouLtvG5MlFjD1NA.png" /><figcaption>Figure 8 — The created templates in the Proxmox admin panel</figcaption></figure><p>Then we clone the templates to create the respective VMs.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6fOI6XjPllbKT-NpeFlHWA.png" /><figcaption>Figure 9 — Clicking the VM cloning button</figcaption></figure><p>It’s important to choose Full Clone and then enable the machine by Right-click -&gt; Start.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/730/1*uS1gkuYB6ZGayczBaTXLfw.png" /><figcaption>Figure 10 — Going through the cloning process and starting the machines.</figcaption></figure><p>In the end of the process we should see multiple VMs enabled on both bare metals.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*3ud5lrZ3UbI2kH9R9pkfcQ.png" /><figcaption>Figure 11 — View of all virtual machines after the cloning process is complete</figcaption></figure><h3>VMs Ubuntu installation</h3><p>After creating the VMs from templates, the next step is installing the Ubuntu Linux system on each node.<br>All machines should be configured consistently using the IP addresses and disk layouts matching what’s defined in<em> Figure 4</em>.</p><p>The installation itself is straightforward. Start by opening the web VNC console in Proxmox and connecting to the VM. From there, you can begin the Ubuntu installer and select the preferred system language.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*QFJfIZEOc-pm1V3m0cP5lw.png" /><figcaption>Figure 12 — Installing Ubuntu via the Proxmox web console</figcaption></figure><p>Then we need to set up the network stack:</p><ul><li><strong>subnet: </strong>10.0.0.0/24 or according to your Proxmox virtual network configuration</li><li><strong>address: </strong>10.0.0.x</li><li><strong>gateway:</strong> 10.0.0.1 or according to your Proxmox virtual network configuration</li><li><strong>nameservers:</strong> 8.8.8.8</li><li><strong>search domains: </strong>yourdomain.com</li></ul><p>I also recommend disabling IPv6 if you don’t plan to use it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/959/1*RZ0TYpRJbUNwPC41Shbvvg.png" /><figcaption>Figure 13— Network settings configuration</figcaption></figure><p>Next, we need to configure the disk partitioning.</p><p>For worker nodes, it’s important to use the correct disks. The smaller disk should be used for the root (/) filesystem, while the larger disk should be left unmounted during installation. It will later be configured by the Ansible scripts and used by Longhorn.</p><p>For the master nodes, only a single disk is used, so the default configuration can be left unchanged.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/950/1*4k14OL8sTpltcwD52ecnMw.png" /><figcaption>Figure 14 — Partitioning configuration</figcaption></figure><p>Next, we configure the initial user and hostname for each machine.<br>The hostnames should match the naming defined in Figure 4, so everything stays consistent across the setup.</p><p>In my case, I use the username cloud. You can choose a different one if you prefer, but keep in mind that you’ll need to update the Ansible variables accordingly. Otherwise, the playbooks won’t be able to connect and execute properly.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/999/1*KRArVZtNl1ZuRuN3-DHxoA.png" /><figcaption>Figure 15 — Setting up the credentials</figcaption></figure><p>At this stage, make sure to enable the OpenSSH server during installation.<br> You can do this by pressing Space when selecting optional components. This allows you to connect to the machine later using the username and password configured in the previous step.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/999/1*9SzUZJN1YWX1Dmj5f6ywRA.png" /><figcaption>Figure 16 — OpenSSH server installation</figcaption></figure><p>Once the installation is complete, simply choose the option to reboot the system.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/912/1*eNHbZJ88bRq8NUrmjCEaMQ.png" /><figcaption>Figure 17 — Installation is complete, we can click the ‘Reboot Now’ button</figcaption></figure><p>After the reboot, the system may prompt you to press ENTER to continue. This final step brings the machine to the login screen, where you can proceed with the configured credentials.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/831/1*rwLY_lD84j6mEtSi1vew0Q.png" /><figcaption>Figure 18 — Final step to enable the VM</figcaption></figure><p>Repeat these steps for all machines.<br> Once everything is installed and configured, you should see all 7 VMs in the Proxmox panel, distributed across the two bare metal hosts as defined in Figure 4.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*w3BcUs5zLF0r8Bl0UJxzdw.png" /><figcaption>Figure 19 — Final view of the Proxmox admin panel, after installing all of the VMs</figcaption></figure><p>Once the base installation is done, we can move on to preparing the nodes for Kubernetes (RKE2).<br>Instead of configuring everything manually, I use Ansible to automate the process and keep the setup consistent across all machines.</p><h3>Using Ansible for configuration</h3><p>You can download the Ansible code that will be used for the RKE2 nodes configuration from <a href="https://github.com/heyimjustalex/medium/tree/main/Article_1"><strong>here</strong></a>. You can clone the repository with git clone command and then move to the correct path with Ansible playbooks and configuration. To do it, run:</p><pre>git clone https://github.com/heyimjustalex/medium.git<br>cd medium/Article_1/ansible_setup_script/</pre><h4>Prepare nodes</h4><p>The hosts are defined in the inventory folder. If you used different IP addresses or hostnames than the ones shown in Figure 4, make sure to update them in the hosts.yml file.</p><pre>all:<br>  children:<br>    masters:<br>      hosts:<br>        k8s-master01:<br>          ansible_host: 10.0.0.28<br>          k8s_zone: proxmox-node0<br>        k8s-master02:<br>          ansible_host: 10.0.0.29<br>          k8s_zone: proxmox-node0<br>        k8s-master03:<br>          ansible_host: 10.0.0.30<br>          k8s_zone: proxmox-node1<br>workers:<br>      hosts:<br>        k8s-worker01:<br>          ansible_host: 10.0.0.31<br>          k8s_zone: proxmox-node0<br>        k8s-worker02:<br>          ansible_host: 10.0.0.32<br>          k8s_zone: proxmox-node0<br>        k8s-worker03:<br>          ansible_host: 10.0.0.33<br>          k8s_zone: proxmox-node1<br>        k8s-worker04:<br>          ansible_host: 10.0.0.34<br>          k8s_zone: proxmox-node1</pre><p>The k8s_zone variable is used later by Longhorn to distribute replicas across zones, which in this setup means across different bare metal hosts.</p><p>Before running the Ansible playbooks, make sure you can connect to all target nodes via SSH at least once using password authentication. This step is important because it adds each host’s fingerprint to your known_hosts file.</p><p>If you skip this, you’ll most likely run into an error like the one below:</p><pre>fatal: [k8s-worker03]: FAILED! =&gt; {&quot;msg&quot;: &quot;Using a SSH password instead of a key is not possible because Host Key checking is enabled and sshpass does not support this. Please add this host&#39;s fingerprint to your known_hosts file to manage this host.&quot;}</pre><p>To avoid this, simply SSH into each node manually:</p><pre>ssh cloud@10.0.0.x</pre><p>For Ansible to execute tasks properly, it needs the ability to run commands with elevated privileges. This means configuring a user with passwordless sudo.</p><p>Log in to each server and add your user to the sudoers configuration. In this example, I’m using the cloud user:</p><pre>echo &quot;cloud ALL=(ALL) NOPASSWD: ALL&quot; | sudo EDITOR=&#39;tee&#39; visudo -f /etc/sudoers.d/99-cloud-ansible</pre><p>This command:</p><ul><li>creates a new file in /etc/sudoers.d/</li><li>adds a rule saying that user cloud can run any command as any user without being asked for a password</li></ul><p>In practice, this means:</p><ul><li>Ansible can run sudo commands without breaking on password prompts</li><li>no need to configure --ask-become-pass</li><li>playbooks run fully unattended</li></ul><blockquote>In more restrictive environments, you might want to limit sudo privileges to specific commands instead of allowing full access. For simplicity and automation, I’m using full passwordless sudo here.</blockquote><figure><img alt="" src="https://cdn-images-1.medium.com/max/1002/1*B0Y8xlCX1OZcSDUQoXtALA.png" /><figcaption>Figure 20 — Executing the visudo command on one of the master nodes</figcaption></figure><h4>Generate SSH key</h4><p>Next, we generate an SSH key pair to avoid using password-based authentication when running Ansible playbooks.</p><pre>ssh-keygen -t ed25519 \<br>  -f ./keys/ansible_ed25519 \<br>  -C &quot;ansible-managed-access&quot;<br></pre><p>This creates a new key pair that will be used by Ansible to securely connect to the target nodes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Q0I7LfMZYjbFMvaBsrVvMg.png" /><figcaption>Figure 21 — Executing SSH key generation command</figcaption></figure><p>Also we should change the permissions for the key</p><pre>chmod 600 Article_1/ansible_setup_script/keys/ansible_ed25519</pre><h4>Generate RKE2 token</h4><p>Before deploying the cluster, we need to generate a shared RKE2 token. This token is used when additional nodes join the cluster — both control plane and worker nodes use it to authenticate and connect to the initial server.</p><p>Because this token effectively grants access to the cluster, it should be treated as sensitive data and not stored in plain text. To keep things secure and still automation-friendly, I store it using Ansible Vault.</p><pre>openssl rand -hex 32<br>ansible-vault create ./group_vars/vault.yml</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/894/1*iT06Q9zB5Hw-QNCj4EQbPw.png" /><figcaption>Figure 22 — Generating the RKE2 token and storing it in the Ansible vault</figcaption></figure><p>Make sure to store the Ansible Vault password you created in a secure place, such as your password manager. You’ll need it whenever you run or modify the playbooks.</p><p>If you want to update the contents of the vault later, you can use:</p><pre>ansible-vault edit ./group_vars/vault.yml</pre><h3>Deploy the RKE2 cluster using the Ansible scripts</h3><p>Now that the base infrastructure is ready, we can move on to automating the cluster setup. The playbooks I use in this section can be found <a href="https://github.com/heyimjustalex/medium/tree/main/Article_1/ansible_setup_script/playbooks">here</a>.</p><p>Before running them, make sure to review the variables defined in group_vars/all.yml and adjust them to match your environment. At a minimum, you’ll likely want to update things like IPs, domains, or software versions depending on your setup.</p><p>1. The first playbook is used to bootstrap SSH access for Ansible across all nodes. Its purpose is to prepare the machines so that Ansible can connect securely using SSH keys instead of passwords.</p><p>It runs on all hosts and uses elevated privileges (become: true) to perform system-level changes.</p><p>To execute it, run:</p><pre>ansible-playbook  playbooks/01-upload-ssh-keys.yml --ask-pass</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/881/1*CKeLawQcI2BcZUKgnUWQVw.png" /><figcaption>Figure 23 — Enabling SSH login on all nodes and uploading the generated SSH keys</figcaption></figure><p>2. The second script focuses on hardening the SSH configuration across all nodes. It applies a set of secure defaults aimed at reducing the attack surface of the system. Instead of leaving SSH in its default, more permissive state, the playbook enforces stricter access rules — such as disabling unnecessary features and tightening login behavior. It also validates the configuration before reloading the SSH service, which helps avoid accidental lockouts. To execute it, run:</p><pre>ansible-playbook  playbooks/02-harden-ssh-config.yml</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/896/1*3IeJzhp76bcDkAUtu_ghaA.png" /><figcaption>Figure 24 — Hardening the SSH config with an Ansible playbook</figcaption></figure><blockquote>It’s worth noting that this step focuses on hardening the SSH service itself, but it does not restrict the privileges of the cloud user. In this setup, the user still has full sudo access without a password, which is convenient for automation but should be reconsidered in production environments.</blockquote><p>3. The third script prepares all nodes so they meet the basic requirements expected by Kubernetes before installation.</p><p>It takes care of system-level configuration that Kubernetes relies on but that isn’t always correctly set by default. This includes adjusting how the OS handles memory and networking, loading required kernel modules, and applying sysctl settings so that cluster networking behaves properly. It also installs a small set of base packages needed for management and tooling, and ensures the environment is consistent across all machines.</p><p>Overall, this step removes a lot of the “it works on some nodes but not others” type of issues. To execute it, run:</p><pre>ansible-playbook playbooks/03-prepare-nodes-for-k8s.yml</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/942/1*xrwGER45a6A7Kjm68vb16A.png" /><figcaption>Figure 25 — Preparing the VMs for RKE2 installation</figcaption></figure><p>4. The fourth script bootstraps the first control plane node, effectively creating the Kubernetes cluster. It installs RKE2 on the initial master, applies the core configuration, and starts the API server. At this point, the cluster becomes accessible and ready to accept additional nodes.</p><p>The script also prepares kubectl access and performs basic validation to ensure the control plane is up and running before continuing with the rest of the deployment. To execute this step, run:</p><pre>ansible-playbook playbooks/04-bootstrap-one-master-node.yml --ask-vault-pass</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/919/1*jFakTLV0mAPZYtJe9N7aYg.png" /><figcaption>Figure 26a — Initializing the first RKE2 node</figcaption></figure><p>You can test whether the node has been initialized by logging into the VM and executing kubectl command (I use an alias k=kubectl).</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/628/1*7i1jgh8cgB0vN7ychNlS7w.png" /><figcaption>Figure 26b — Testing whether the first node has been initialized by hands.</figcaption></figure><p>5. The fifth script introduces kube-vip<strong>,</strong> which acts as a lightweight load balancer for the Kubernetes control plane.</p><p>In a bare-metal setup, there’s no cloud load balancer available to expose the API server behind a stable endpoint. kube-vip solves this by providing a virtual IP address that is shared across control plane nodes. At any point in time, one node “owns” the IP, and if that node goes down, the IP automatically moves to another one. This ensures that the Kubernetes API remains accessible.</p><p>This script installs kube-vip directly into the cluster as a DaemonSet and configures it to manage the control plane endpoint. From this point on, all nodes — and later also external tools — can communicate with the cluster using a single, stable IP address instead of pointing to a specific master node.</p><p>In practice, this is what enables a highly available control plane without relying on external load balancing components. To execute the playbook, run:</p><pre>ansible-playbook  playbooks/05-upgrade-one-master-node-kube-vip.yml --ask-vault-pass</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/947/1*Bv0EoxSiAI8a76S0H7jyZQ.png" /><figcaption>Figure 27 — Installing the kube-vip for the first master node.</figcaption></figure><p>6. The sixth script brings the remaining control plane nodes into the cluster and turns the setup into a fully highly available control plane.</p><p>It installs RKE2 on the other master nodes and joins them to the cluster using the virtual IP provided by kube-vip, instead of connecting to a specific node. This is an important detail, because it ensures that all nodes rely on a stable entry point from the start.</p><p>It also propagates the kube-vip configuration to the new nodes, so each control plane node is capable of participating in managing the shared IP. After that, the services are restarted to apply everything consistently.</p><p>At this stage, the control plane is no longer a single-node setup. It becomes distributed, with multiple masters sharing responsibility and providing redundancy if one of them fails. The script also performs validation to ensure that the new nodes have successfully joined the cluster. Instead of just checking basic connectivity, it verifies that each node appears in the cluster and reaches a Ready state, confirming that it is fully operational.</p><pre>ansible-playbook playbooks/06-install-and-join-other-master-nodes.yml --ask-vault-pass</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/893/1*K00bp4RSrqmvBxBoXxWFZA.png" /><figcaption>Figure 28 — Joining the cluster with the other 2 master nodes.</figcaption></figure><p>We can also test manually whether the nodes joined the cluster, by logging in and executing relevant kubectl command:</p><pre>kubectl get nodes</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*TMcFKFzS18hlhDoARLUXHg.png" /><figcaption>Figure 29 — Testing manually whether the master nodes joined the cluster</figcaption></figure><p>7. The seventh script is mainly about validating that kube-vip is working correctly and making sure the cluster consistently uses it as the control plane endpoint.</p><p>It starts by verifying that kube-vip is properly deployed across all control plane nodes and that it’s actually functional — including checking whether the Kubernetes API is reachable through the virtual IP. This confirms that the control plane load balancing is working as expected.</p><p>The second part of the script standardizes access to the cluster by updating all kubeconfig files on every master node. Instead of pointing to a specific node, they are rewritten to use the kube-vip address. This ensures that all interactions with the cluster always go through the highly available endpoint.</p><p>At this point, the setup is fully normalized — no matter which node you connect from, you always interact with the cluster through the same stable API endpoint. To execute the playbook, run:</p><pre>ansible-playbook playbooks/07-validate-and-normalize-kube-vip.yml --ask-vault-pass</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/872/1*m-AM86zIF7DFl16zMhjkCg.png" /><figcaption>Figure 30 — Validation and normalization of kube-vip settings</figcaption></figure><p>8. The eighth script installs RKE2 on the worker nodes and joins them to the cluster.</p><p>Similarly to the control plane nodes, workers connect to the cluster using the kube-vip address instead of a specific master node. This ensures that they rely on the same stable entry point and remain unaffected by individual control plane node failures.</p><p>Once installed, each worker registers itself with the cluster and becomes available to run workloads. The script also includes validation to confirm that the nodes have successfully joined and reached the Ready state.</p><p>At this point, the cluster becomes fully functional — with a distributed control plane and worker nodes ready to host applications. To execute the playbook, run:</p><pre>ansible-playbook playbooks/08-install-rke-on-worker-nodes-and-join.yml --ask-vault-pass</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/942/1*0vdHjYD-dt2iu0BDFgIPPA.png" /><figcaption>Figure 31 — Worker nodes are joining the cluster</figcaption></figure><p>9. The ninth script installs a set of additional utilities that make working with the cluster more practical on a daily basis.</p><p>It ensures that crictl is available and properly configured to interact with the container runtime used by RKE2. This is useful for low-level debugging and inspecting containers directly on the nodes. It also installs Helm on the control plane nodes, which is commonly used for deploying applications into the cluster.</p><p>At this point, the cluster is not only up and running, but also equipped with the basic tooling needed for troubleshooting, management, and application deployment. To execute this playbook, run:</p><pre>ansible-playbook playbooks/09-install-other-utilities.yml</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/965/1*zx-WxMzxJCy95TPQh6wPFA.png" /><figcaption>Figure 32 — Installing utilities on the nodes with an Ansible playbook</figcaption></figure><p>Before executing the next playbook it’s worth briefly explaining what Longhorn is.</p><p>Longhorn is a distributed block storage system designed for Kubernetes. It allows you to attach persistent volumes to workloads while replicating data across multiple nodes. In practice, it gives you highly available storage without relying on external SAN or cloud solutions, which makes it a very natural fit for a bare-metal setup like this one.</p><p>10. The tenth script prepares dedicated disks on the worker nodes so they can be used by Longhorn.</p><p>It ensures that each node has a properly configured storage device, formats it if needed, and mounts it under a consistent path. The configuration is made persistent, so the disks are automatically mounted after reboot.</p><p>The script also performs validation to confirm that the disk is available and correctly mounted before moving forward. This step ensures that all worker nodes provide reliable storage for Longhorn, which is later used to back Kubernetes volumes.</p><blockquote>Note that this is why the larger disk was not used for the operating system on the worker nodes during the Proxmox setup, but instead reserved specifically for Longhorn storage.</blockquote><p>To execute the playbook, run:</p><pre>ansible-playbook playbooks/10-prepare-disks-for-longhorn.yml</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1008/1*52qgCAufBCSo9_ZwsPRWQA.png" /><figcaption>Figure 33 — Preparing the disks for Longhorn</figcaption></figure><p>11. The eleventh script introduces a simple but important concept — <strong>topology-aware storage using zones</strong>.</p><p>In this setup, the two Proxmox hosts act as separate failure domains. Each one is treated as its own “zone”, and nodes are labeled accordingly. This allows Longhorn to understand which nodes are physically separated and distribute replicas across them.</p><p>In practice, this means that when Longhorn creates a volume, it doesn’t store all replicas on nodes running on the same bare metal. Instead, it spreads them across both hosts. As a result, even if one entire Proxmox node goes down, the data is still available from the other one.</p><p>The script applies these topology labels to all Kubernetes nodes based on values defined in the inventory. Once applied, the cluster becomes aware of node placement, which enables Longhorn to make smarter decisions about data replication and improve overall resilience. To execute this playbook, run:</p><pre>ansible-playbook playbooks/11-topology-labeling-nodes-for-longhorn.yml</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/978/1*s76ZPPYBb-Ux3HvZtV64ig.png" /><figcaption>Figure 34 — Labeling the worker nodes, so the Longhorn replicas are distributed across two bare-metal servers</figcaption></figure><p>12. The 12th playbook focuses on making DNS resolution and image pulls more predictable across the cluster.</p><p>It replaces systemd-resolved with a static DNS configuration, so all nodes use explicitly defined resolvers. It also updates CoreDNS, which handles both internal service discovery and external DNS queries inside the cluster, ensuring that external requests are forwarded reliably.</p><p>In addition, it configures containerd registry endpoints to make image pulls more consistent and prepares the system for pulling from different registries. Finally, it restarts the components and verifies that image downloads are working. To execute this playbook, run:</p><pre>ansible-playbook playbooks/12-enforce-coredns-config.yml</pre><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*aaO_AwyVS1JDqm9kfTRSjA.png" /><figcaption>Figure 35 — Enforcing the coredns configuration across the RKE2 nodes</figcaption></figure><p>13. The last, 13th playbook is essentially a <strong>resilience test for the whole cluster</strong>.</p><p>Instead of just checking whether the installation finished successfully, it verifies that the environment can survive controlled reboots without losing availability. It starts with a pre-flight validation to confirm that the cluster is healthy before any disruption is introduced. From there, it checks whether key configuration elements — especially Longhorn disk mounts and utility setup — are still present before rebooting anything.</p><p>The reboot itself is done in a <strong>rolling manner</strong>. Worker nodes are restarted first, because they do not affect etcd quorum, and each one is validated after coming back online. Only after that does the script move on to the control plane nodes, rebooting them one at a time to avoid breaking quorum. During this phase, it also verifies that kube-vip continues to expose the Kubernetes API through the virtual IP, which is critical for control plane availability.</p><p>Finally, the playbook performs a cluster-wide post-flight validation. It confirms that all nodes returned to the Ready state, kube-vip is healthy, the virtual IP is owned by exactly one master, and Longhorn storage is still mounted on the worker nodes. In other words, this is not just a reboot script — it is a practical validation that the cluster behaves like a highly available platform even after node restarts. To execute the validation playbook, run:</p><pre>ansible-playbook playbooks/13-final-tests-reboot-and-validate.yml</pre><figure><img alt="" src="https://cdn-images-1.medium.com/proxy/1*C9mPf3lpWSCPiVJHjbLGvw.png" /><figcaption>Figure 36 — Testing the cluster availability and configuration durability with an Ansible playbook</figcaption></figure><h4>Final step</h4><p>As a final step, we copy the kubeconfig file from the control plane node to the local machine. This file contains the credentials and endpoint needed to interact with the cluster using kubectl.</p><pre>scp cloud@10.0.0.28:/etc/rancher/rke2/rke2.yaml ~/.kube/config<br>chmod 600 ~/.kube/config</pre><p>In this setup, the Ansible playbooks already configure the cluster to use the kube‑vip address for control plane communication, so no manual changes should be required.</p><p>You can still open the file and verify the API server endpoint if needed — for example, to confirm that it points to the kube‑vip address (in my case 10.0.0.41) rather than a specific node:</p><pre>vim ~/.kube/config</pre><p>After this, you should be able to manage the cluster directly from your local environment using kubectl (assuming you are connected to the cluster network, for example via VPN).</p><h3>Summary</h3><p>In this first part, we went from bare metal to a fully functional Kubernetes cluster built with RKE2 and automated entirely with Ansible.</p><p>Instead of treating Kubernetes as a single component, the focus here was on building a solid foundation — understanding how each layer fits together, from physical infrastructure and virtualization to control plane design, networking, and storage prerequisites.</p><p>Along the way, we introduced key concepts that make this setup feel much closer to a real platform than a simple lab:</p><ul><li><strong>a</strong> <strong>highly available control plane</strong> using kube-vip instead of external load balancers</li><li><strong>automated provisioning</strong> with Ansible, ensuring consistency across all nodes</li><li><strong>topology-aware storage design</strong> that prepares the cluster for resilient Longhorn deployments</li><li>and a validation approach that actually tests real-world behavior, like node reboots and failover</li></ul><p>What we ended up with is not just a running cluster, but a platform-ready baseline that is predictable, reproducible, and aligned with real-world deployments.</p><p>In the next part, we’ll start building on top of this foundation by adding the key platform components: authentication, ArgoCD for GitOps, the Rancher management layer, and Longhorn for distributed storage.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=dd85c9aa244d" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>