[{"content":"","date":"6 September 2026","externalUrl":null,"permalink":"/","section":"Blog | Haeger","summary":"","title":"Blog | Haeger","type":"page"},{"content":"","date":"6 September 2026","externalUrl":null,"permalink":"/tags/devops/","section":"Tags","summary":"","title":"DevOps","type":"tags"},{"content":"","date":"6 September 2026","externalUrl":null,"permalink":"/tags/kubernetes/","section":"Tags","summary":"","title":"Kubernetes","type":"tags"},{"content":"","date":"6 September 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"6 September 2026","externalUrl":null,"permalink":"/tags/security/","section":"Tags","summary":"","title":"Security","type":"tags"},{"content":"Kubernetes Secrets are not encrypted, they are base64-encoded. Commit one to your repository and you have committed plaintext credentials that anyone with read access can decode. That rules out the obvious GitOps approach: everything in Git, except the secrets, which you then apply by hand and go hunting for the next time you rebuild.\nSealed Secrets fixes this. A controller in your cluster encrypts secrets so the ciphertext can safely live in your repository. This guide walks through setting it up in a fresh cluster, including the two things worth getting right from the start.\nHow it works # On first start, the controller generates an RSA key pair and stores it as a Secret in the cluster:\nThe public key encrypts. The kubeseal CLI on your machine uses it. The private key decrypts. It never leaves the cluster. You create a SealedSecret locally, commit it, and the controller turns it into a regular Secret inside the cluster. The ciphertext is only usable by that one cluster. That is the heart of the mechanism and also where most people trip up later.\nStep 1: Install the controller # Using Helm:\nhelm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets helm install sealed-secrets sealed-secrets/sealed-secrets \\ --namespace kube-system The namespace is a convention, but it matters: kubeseal looks for a controller named sealed-secrets-controller in kube-system by default. Install it elsewhere and you will be passing flags on every invocation.\nVerify:\nkubectl get pods -n kube-system -l name=sealed-secrets-controller Step 2: Install the CLI # brew install kubeseal # macOS Linux binaries are on the releases page. Keep the CLI roughly in step with the controller, large version gaps between the two are an unnecessary source of trouble.\nkubeseal --version Step 3: Back up the sealing key # This is the most important step, and it\u0026rsquo;s almost always skipped. Do it right after installing.\nkubectl get secret -n kube-system \\ -l sealedsecrets.bitnami.com/sealed-secrets-key \\ -o yaml \u0026gt; sealed-secrets-key-backup.yaml This file is the master key to every secret you will ever seal. It belongs in a password manager or an encrypted offline backup, never in the same repository as your sealed secrets. Otherwise you have defeated the very thing you adopted this for.\nWhy the urgency? See \u0026ldquo;Cluster binding\u0026rdquo; below.\nStep 4: Seal a secret # The common case: registry credentials so your cluster can pull private images. Create the secret locally without applying it:\nkubectl create secret docker-registry dockerhub-secret \\ --docker-server=https://index.docker.io/v1/ \\ --docker-username=\u0026lt;user\u0026gt; \\ --docker-password=\u0026lt;token\u0026gt; \\ --namespace=my-app \\ --dry-run=client -o yaml \u0026gt; secret.yaml --dry-run=client is the critical part: the secret lands on disk, not in the cluster. Use a registry access token rather than your account password. Tokens can be revoked individually.\nThen seal it:\nkubeseal --format yaml \u0026lt; secret.yaml \u0026gt; sealed-secret.yaml kubeseal fetches the public key from the controller itself. You can also work offline by exporting the key once:\nkubeseal --fetch-cert \u0026gt; pub-cert.pem kubeseal --format yaml --cert pub-cert.pem \u0026lt; secret.yaml \u0026gt; sealed-secret.yaml That\u0026rsquo;s the usual approach for CI pipelines, which shouldn\u0026rsquo;t need cluster access.\nNow sealed-secret.yaml goes into Git and secret.yaml goes into .gitignore.\nStep 5: Do not forget the namespace # A sealed secret is bound to both name and namespace. That is not incidental, it is deliberate: otherwise someone could copy your sealed secret into a namespace they control and have it decrypted there.\nIn practice this means the namespace has to exist, and it should live in your repository as a manifest, not just in your head or in some old shell history.\napiVersion: v1 kind: Namespace metadata: name: my-app Step 6: Apply and verify # kubectl apply -f namespace.yaml -f sealed-secret.yaml Within seconds the controller should produce a regular Secret:\nkubectl get secret dockerhub-secret -n my-app kubectl get sealedsecret dockerhub-secret -n my-app \\ -o jsonpath=\u0026#39;{.status.conditions}\u0026#39; Success shows as Synced=True. The more convincing test, though, is your workload: if the pod runs and pulled its image, the credentials are correct in substance too.\nspec: containers: - name: app image: myuser/myimage:latest imagePullSecrets: - name: dockerhub-secret Cluster binding: the part that bites later # A sealed secret can only be decrypted by the cluster whose public key encrypted it. Stand up a new cluster and its fresh controller generates a new key pair. Your ciphertext in Git is worthless there.\nThe symptom is quiet: the SealedSecret applies fine, but no Secret appears. The status and controller log say no key could decrypt secret. Meanwhile your pod sits in ImagePullBackOff and you go looking for the problem in your Deployment.\nThree ways out:\nWith a key backup, the good case. Restore the saved key before the controller starts for the first time:\nkubectl apply -f sealed-secrets-key-backup.yaml kubectl delete pod -n kube-system -l name=sealed-secrets-controller The controller picks up existing keys on startup and would not generate a new pair. Every sealed secret you have keeps working, untouched.\nWith the plaintext secret but no key backup reseal:\nkubeseal --fetch-cert \u0026gt; pub-cert.pem # the new cluster\u0026#39;s key kubeseal --format yaml --cert pub-cert.pem \u0026lt; secret.yaml \u0026gt; sealed-secret.yaml The credentials stay the same; only the encryption is renewed.\nWith neither, the credentials are gone and have to be reissued: a new registry token, a new API key, a password reset. That is not a flaw in the mechanism, it is the point of it.\nWhat else tends to go wrong # Secrets maintained in two places. If the same sealed secret lives in both deployment.yaml and sealed-secret.yaml, someone will eventually update only one of them. One object belongs in exactly one file.\nCommitting secret.yaml by accident. The plaintext file is an intermediate artifact and easy to forget. Set up .gitignore from the start, and if in doubt, rotate the token. Once pushed, it is in the history.\nForgetting about rotation. The controller periodically generates new keys and keeps the old ones for decryption. Existing sealed secrets keep working, but your backup quietly goes stale. Refresh it occasionally, or it will cover only part of your secrets when you need it.\nWrong namespace. Moving a secret to a different namespace means resealing it. Alternatively, kubeseal --scope namespace-wide or --scope cluster-wide allow looser bindings, which deliberately weakens the protection, so it stays a trade-off.\nChecklist # Controller running, CLI version roughly matching Sealing key backed up outside the repository secret.yaml in .gitignore Namespaces committed as manifests Every object lives in exactly one file Registry access via revocable tokens, not account passwords Key backup refreshed after rotations Takeaway # Sealed Secrets make full GitOps practical: your entire cluster state lives in the repository, secrets included. The price is one key you have to look after and the time to do that is at installation, not at the moment you need it.\n","date":"6 September 2026","externalUrl":null,"permalink":"/posts/kubeseal/","section":"Posts","summary":"","title":"Setting Up Sealed Secrets in a New Kubernetes Cluster","type":"posts"},{"content":"","date":"6 September 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"A freshly bootstrapped Kubernetes cluster is functional, but it is not secure by default. The CIS Kubernetes Benchmark from the Center for Internet Security is the most widely used baseline for locking a cluster down. It is a long checklist of concrete settings covering the API server, the controller manager, the scheduler, etcd, the kubelet, and general cluster policies. Going through it by hand is slow and error prone, and the result drifts the moment someone changes a flag. This post shows how to assess and enforce the benchmark automatically.\nWhy automate it # The benchmark has well over a hundred individual controls, and most clusters have more than one node. Checking each control manually on every node does not scale, and a one time manual pass tells you nothing about the state of the cluster next week. Automation gives you three things that a manual review cannot. You get a repeatable assessment that produces the same report every time, you get continuous verification so configuration drift is caught early, and you get remediation as code so the hardened state is reproducible on a new cluster.\nThe usual split is to separate the two halves of the job. Assessment answers the question of where the cluster fails the benchmark, and remediation actually changes the configuration to fix those failures. It is good practice to keep them apart so you can audit first, understand the impact, and only then enforce.\nStep 1: Assess with kube-bench # kube-bench from Aqua Security is the de facto tool for checking a cluster against the CIS benchmark. It inspects the running configuration of each component, compares it against the benchmark, and reports every control as pass, fail, or warn. It detects the Kubernetes version and selects the matching benchmark automatically.\nThe cleanest way to run it is as a Job on the cluster itself, because it needs access to the host filesystem to read the component manifests and config files.\nkubectl apply -f https://raw.githubusercontent.com/aquasecurity/kube-bench/main/job.yaml # wait for the job to finish, then read the report kubectl logs job/kube-bench On a control plane node you can also run it directly against the host. This is handy in a CI pipeline or during the initial build before the cluster serves traffic.\n# run the control plane checks and emit JSON for machine processing kube-bench run --targets master --json \u0026gt; kube-bench-report.json The JSON output is the important part for automation. Every control carries a test number, a description, the result, and a remediation hint. You can feed this into a pipeline and fail the build when any control regresses.\n# fail the pipeline if kube-bench reports any failed control kube-bench run --targets master --json \\ | jq -e \u0026#39;.Totals.total_fail == 0\u0026#39; \u0026gt; /dev/null \\ || { echo \u0026#34;CIS benchmark failures found\u0026#34;; exit 1; } Step 2: Understand a typical finding # It helps to look at what a single control actually means before automating the fix. A common failure on a fresh cluster is the kubelet anonymous authentication setting. By default the kubelet may accept unauthenticated requests on its API, which lets anyone who can reach the node read pod information or trigger actions. The benchmark requires anonymous access to be turned off.\nThe fix lives in the kubelet configuration file, usually /var/lib/kubelet/config.yaml.\n# /var/lib/kubelet/config.yaml authentication: anonymous: enabled: false webhook: enabled: true authorization: mode: Webhook After changing the config you restart the kubelet so the new settings take effect.\nsudo systemctl restart kubelet Most control plane controls work the same way. They are flags on the static pod manifests in /etc/kubernetes/manifests, such as kube-apiserver.yaml, and editing the manifest makes the kubelet recreate the pod with the new settings.\nStep 3: Enforce with Ansible # Hand editing files does not scale across nodes, so the remediation belongs in a configuration management tool. Ansible is a natural fit because it is agentless and idempotent, which means you can run the same playbook repeatedly and it only changes what is not already correct. The pattern is one task per benchmark control, so the playbook reads like a hardened version of the checklist itself.\n# harden-kubelet.yml - name: Harden kubelet according to CIS benchmark hosts: all become: true tasks: - name: Disable anonymous auth on the kubelet ansible.builtin.replace: path: /var/lib/kubelet/config.yaml regexp: \u0026#39;anonymous:\\n\\s*enabled: true\u0026#39; replace: \u0026#34;anonymous:\\n enabled: false\u0026#34; notify: restart kubelet - name: Set kubelet config file permissions to 0600 ansible.builtin.file: path: /var/lib/kubelet/config.yaml owner: root group: root mode: \u0026#39;0600\u0026#39; handlers: - name: restart kubelet ansible.builtin.systemd: name: kubelet state: restarted If you would rather not write every control yourself, there are maintained roles that already encode the full benchmark, such as the ansible-lockdown Kubernetes role. These let you toggle individual controls through variables, which is useful because some controls do not fit every environment and need to be reviewed before they are enforced.\nStep 4: Keep it from drifting # A hardened cluster slowly drifts as people debug issues and forget to revert a flag, so the assessment has to run on a schedule rather than once. A simple and effective approach is a CronJob that runs kube-bench inside the cluster and ships the result to wherever you collect logs or alerts.\n# kube-bench-cronjob.yaml apiVersion: batch/v1 kind: CronJob metadata: name: kube-bench namespace: security spec: schedule: \u0026#34;0 3 * * *\u0026#34; # every night at 03:00 jobTemplate: spec: template: spec: hostPID: true containers: - name: kube-bench image: aquasec/kube-bench:latest command: [\u0026#34;kube-bench\u0026#34;, \u0026#34;run\u0026#34;, \u0026#34;--targets\u0026#34;, \u0026#34;node\u0026#34;, \u0026#34;--json\u0026#34;] restartPolicy: Never For a stronger guarantee you can stop bad configuration before it ever reaches the cluster. An admission policy engine such as Kyverno or OPA Gatekeeper enforces many of the workload related benchmark controls at admission time, for example by rejecting privileged containers or pods that mount the host filesystem. This pairs well with kube-bench, because kube-bench audits the cluster components while the admission policies guard the workloads running on top.\nA practical workflow # Putting the pieces together gives a clear and repeatable process. You assess the cluster with kube-bench and capture the JSON report, you review the failures and decide which controls apply to your environment, you encode the fixes as an Ansible playbook and run it across all nodes, and you schedule kube-bench to run nightly so any drift shows up the next morning. Workload controls are handled by admission policies so insecure pods never start in the first place.\nThe result is a cluster that is not only hardened once, but stays hardened, with the security posture living in version control next to the rest of your infrastructure code.\nFurther reading # CIS Kubernetes Benchmark kube-bench documentation ","date":"26 June 2026","externalUrl":null,"permalink":"/posts/cis_k8s_hardening/","section":"Posts","summary":"","title":"Automated CIS Hardening of Kubernetes Clusters","type":"posts"},{"content":"This guide walks through bootstrapping a Kubernetes cluster with kubeadm. The flow is: prepare every host identically, initialize the first control plane node, install a pod network (CNI), and finally join additional control plane and worker nodes.\nPrerequisites (on every host) # These steps must run on every node, both control plane and workers, because the kubelet expects the same base environment everywhere.\nDisable swap memory # Kubernetes requires swap to be off (by default). The scheduler makes placement decisions based on real available memory; if the kernel can silently page memory to disk, those guarantees break and the kubelet refuses to start. We disable it both at runtime and persistently so it stays off after a reboot.\n# disable swap sudo swapoff -a # disable swap persistent (remove form /etc/fstab) sudo sed -i \u0026#39;/ swap / s/^\\(.*\\)$/#\\1/g\u0026#39; /etc/fstab free -h # swap should be 0 Install containerd # Kubernetes does not run containers itself. It talks to a container runtime through the CRI (Container Runtime Interface). containerd is a lightweight, reliable, CRI-native runtime and the common default.\nThe key detail is the cgroup driver: both the kubelet and the runtime must manage cgroups the same way. On modern systemd-based distros that means SystemdCgroup = true. A mismatch here leads to nodes that become unstable under load. We also pin the pause (sandbox) image, the tiny container that holds the Linux namespaces shared by all containers in a pod.\n# update packages in apt package manager sudo apt update # install containerd using the apt package manager # containerd is lightwieght, reliable and fast (CRI native) sudo apt-get install -y containerd # create /etc/containerd directory for containerd configuration sudo mkdir -p /etc/containerd # Generate the default containerd configuration # Change the pause container to version 3.10 (pause container holds the linux ns for Kubernetes namespaces) # Set `SystemdCgroup` to true to use same cgroup drive as kubelet containerd config default | sed \u0026#39;s/SystemdCgroup = false/SystemdCgroup = true/\u0026#39; | sed \u0026#39;s|sandbox_image = \u0026#34;.*\u0026#34;|sandbox_image = \u0026#34;registry.k8s.io/pause:3.10\u0026#34;|\u0026#39; | sudo tee /etc/containerd/config.toml \u0026gt; /dev/null # Restart containerd to apply the configuration changes sudo systemctl restart containerd Network configuration # The Linux kernel does not forward packets between interfaces by default. Pod networking relies on this forwarding so traffic can be routed between pods and nodes, so we enable ip_forward and persist it across reboots via a sysctl drop-in file.\n# sysctl params required by setup, params persist across reboots cat \u0026lt;\u0026lt;EOF | sudo tee /etc/sysctl.d/k8s.conf net.ipv4.ip_forward = 1 EOF # Apply sysctl params without reboot sudo sysctl --system Install kubeadm, kubelet, and kubectl # These are the three core tools:\nkubeadm bootstraps and joins clusters. kubelet is the node agent that runs on every host and starts pods. kubectl is the CLI used to talk to the cluster. We add the official Kubernetes apt repository (signed with its GPG key for package authenticity) and then apt-mark hold the packages so an unattended apt upgrade can\u0026rsquo;t skip a minor version and break the cluster. Kubernetes upgrades must be done deliberately, one minor version at a time.\n# Update the `apt` package index and install packages needed to use the Kubernetes `apt` repository: sudo apt update # apt-transport-https may be a dummy package; if so, you can skip that package sudo apt-get install -y apt-transport-https ca-certificates curl gpg # Download the public signing key for the Kubernetes package repositories. The same signing key is used for all repositories so you can disregard the version in the URL: # If the directory `/etc/apt/keyrings` does not exist, it should be created before the curl command. sudo mkdir -p -m 755 /etc/apt/keyrings sudo curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.36/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg # Add the appropriate Kubernetes `apt` repository. Please note that this repository have packages only for Kubernetes 1.36; for other Kubernetes minor versions, you need to change the Kubernetes minor version in the URL to match your desired minor version (you should also check that you are reading the documentation for the version of Kubernetes that you plan to install). echo \u0026#39;deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.36/deb/ /\u0026#39; | sudo tee /etc/apt/sources.list.d/kubernetes.list sudo apt-get update sudo apt-get install -y kubelet kubeadm kubectl sudo apt-mark hold kubelet kubeadm kubectl # (Optional) Enable the kubelet service before running kubeadm sudo systemctl enable --now kubelet Initialize the cluster (only from the first control plane) # Run this only on the first control plane node. kubeadm init generates the cluster\u0026rsquo;s certificates, starts the control plane components (API server, controller manager, scheduler, etcd), and prints the kubeadm join commands you will need for the other nodes.\nPre-pulling images first makes the init faster and surfaces any registry/network problems early. The --pod-network-cidr must match what your CNI expects, and 192.168.0.0/16 is Calico\u0026rsquo;s default. The kubeadm reset line is only needed when you are retrying a failed init on an already-touched node.\n# pull images sudo kubeadm config images pull # execute before every new try sudo kubeadm reset --cri-socket=unix:///run/containerd/containerd.sock -f # initialize controlplane sudo kubeadm init --pod-network-cidr=192.168.0.0/16 --cri-socket=unix:///run/containerd/containerd.sock # HOW TO RESET IF NEEDED # sudo kubeadm reset --cri-socket=unix:///run/containerd/containerd.sock # sudo rm -rf /etc/kubernetes /var/lib/etcd After init succeeds, copy the admin kubeconfig into your home directory so kubectl can authenticate as a regular (non-root) user. The admin.conf holds the client certificate that grants cluster-admin access.\n# ONLY ON CONTROL PLANE (also in the output of \u0026#39;kubeadm init\u0026#39; command) mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config # set alias alias k=kubectl # copy kubeadm join command!! Tip: Save the kubeadm join command from the init output now. You\u0026rsquo;ll need it (with a fresh token) to add worker and control plane nodes later.\nInstall a Container Network Interface (CNI) # Right after init, nodes show as NotReady and pods stay Pending because there is no pod network yet. The CNI plugin provides pod-to-pod networking across nodes. Here we use Calico, installed via its Tigera operator. Once the Calico pods are running, the nodes flip to Ready.\nkubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.24.1/manifests/tigera-operator.yaml kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.24.1/manifests/custom-resources.yaml # wait for the pods to be ready k get po -A -w Join the cluster with another control plane # For a highly available (HA) control plane you add more control plane nodes. Unlike a worker, a new control plane needs a copy of the cluster certificates, so you first re-upload them and get a short-lived certificate key. Join tokens also expire (24h by default), so generate a fresh one with token create.\nHA note: A multi control plane setup requires a stable controlPlaneEndpoint (a load balancer / VIP in front of the API servers). If it wasn\u0026rsquo;t set at init time, add it to the kubeadm-config ConfigMap in the kube-system namespace, e.g. controlPlaneEndpoint: 10.9.137.195:6443.\n# create certificate key sudo kubeadm init phase upload-certs --upload-certs sudo kubeadm token create --print-join-command # add the following entry to the kubeadm-config ConfigMap in the kube-system namespace: controlPlaneEndpoint: 10.9.137.195:6443 kubeadm join 192.168.0.200:6443 --token xxxxxx.xxxxxxxxxxxxxxxx \\ --discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \\ --control-plane --certificate-key xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx Join the cluster with worker nodes # Worker nodes run your actual workloads. They only need to authenticate to the API server, so the join command is simpler and needs just the token and the CA cert hash (the hash lets the node verify it\u0026rsquo;s talking to the right cluster). No certificate key and no --control-plane flag here.\nkubeadm join 10.9.137.162:6443 --token xxxxxx.xxxxxxxxxxxxxxxx \\ --discovery-token-ca-cert-hash sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ","date":"26 June 2026","externalUrl":null,"permalink":"/posts/kubeadm_cluster_install/","section":"Posts","summary":"","title":"Installing a kubernetes cluster with kubeadm on Ubuntu 24.04 hosts","type":"posts"},{"content":"","date":"22 March 2026","externalUrl":null,"permalink":"/tags/broadcom/","section":"Tags","summary":"","title":"Broadcom","type":"tags"},{"content":"","date":"22 March 2026","externalUrl":null,"permalink":"/tags/conference/","section":"Tags","summary":"","title":"Conference","type":"tags"},{"content":"","date":"22 March 2026","externalUrl":null,"permalink":"/tags/vmug/","section":"Tags","summary":"","title":"VMUG","type":"tags"},{"content":" This week, I attended the VMware User Group Connect Conference in the Amsterdam RAI. The event focused on bringing Broadcom’s VCF customers together to discuss cloud and VMware-related technologies.\nThe conference took place from March 17 to March 19, 2026, and featured a diverse program of presentations, open discussions, and hands-on labs.\nFirst Day - 17.03.2026 # LLMs, GPUs, and runtime realities by Frank Denneman # I kicked off the first day by attending a session by Frank Denneman on LLMs, GPUs and AI.\nAI is shifting infrastructure away from traditional models based on oversubscription. GPUs break this approach with strict placement constraints and unpredictable usage. Additionally, AI workloads use GPU memory differently, combining fixed model allocation with dynamic, fluctuating demand during runtime.\nAfter outlining these changes, Frank opened a discussion with the audience about current and upcoming AI use cases, the challenges they are facing, as well as the types and scale of GPUs being used.\nA key topic was that many AI workloads require multiple GPUs. Not primarily for compute, but due to memory demands. Large models often exceed the capacity of a single GPU (e.g., around 140GB for a 70B model), making distribution across multiple GPUs necessary.\nIn addition, dynamic runtime memory, such as KV cache and activations, introduces unpredictable overhead. This makes capacity planning more complex, as it must account for worst-case scenarios like maximum context size and user concurrency.\nAfter that, Frank explained the differences between various GPUs, how they store and share data, their connection types (PCIe and NVLink) and how these impact bandwidth.\nKubernetes and Cloud Foundry: Defining your Cloud Strategy by Marc van de Logt # Unfortunately, I had to leave after about an hour to attend Marc van de Logt’s session on Kubernetes and Cloud Foundry.\nIn this session, Marc provided a comparison of Kubernetes and Cloud Foundry, starting with a brief introduction to both approaches. As I am already familiar with the Kubernetes ecosystem, there was little new for me on that side. However, it was my first exposure to Cloud Foundry.\nCloud Foundry is a platform-as-a-service (PaaS) that abstracts away the underlying infrastructure, allowing developers to deploy and run applications without needing deep knowledge of the operating system or platform. It provides a developer-focused experience with built-in services for deployment, scaling, and lifecycle management.\nUsing Cloud Foundry, developers can deploy applications with a single command, cf push, which automatically uploads the app, provisions resources, and starts it on the platform without requiring manual configuration.\nVCF: Deployment, Automation \u0026amp; Networking by Daniel Krieger and John Nicholson # After a short break I went to an open discussion about VCF.\nThe main focus was on whether customers were using overlay segmentation and VPCs, as well as the number of network cards and switches in use. The discussion was heavily centered on networking, less on deployment and automation.\nEnabling the Private Cloud Operating Model with VCF9 by Yves Hertoghs # My final session of the first day highlighted how VCF 9 enables network and service isolation, simplifies management, and supports scalable, multi-tenant environments. Key points included traditional roles in a VPC, the new All-Apps Organization, and the VPC construct in VCF Automation, which together offer greater flexibility and capabilities beyond a simple overlay network design.\nSecond Day - 18.03.2026 # From Roadmap to Implementation: Key Innovations Redefining Storage and Cyber Resiliance for VCF 9 by Duncan Epping # The second day started in the large Elicium hall, following a brief kickoff speech by Brad Thomkins, with a presentation by Duncan Epping. The session provided an overview of several enhancements that are planned for upcoming releases, although no specific versions or timelines were disclosed.\nOverall, the presentation focused on the continued evolution of VCF and its integration with modern cloud-native capabilities, particularly in the context of vSphere Kubernetes Services. It highlighted ongoing efforts to improve flexibility, security, and data management within these environments.\nIn addition, upcoming improvements in the area of disaster recovery were discussed, with a focus on strengthening resilience and providing more advanced recovery options. The session also touched on enhanced visibility into system changes over time, enabling better analysis and decision-making during recovery scenarios.\nShrink the Blast Radius: Private Cloud Segmentation Made Easy by Chris McCain # The next session also took place in the Elicium hall and was presented by Chris McCain. It provided a compact overview of the VMware vDefend tools, with a particular focus on how the NSX Distributed Firewall works and on the Security Services Platform, especially regarding the detection and classification of attack vectors.\nHypervisor Horror: Root Access and Regulatory Rage by Christian Mohn and Stine Elise # After that, we left the Elicium hall and moved on to the smaller sessions. Following a short break, a presentation by Christian Mohn and Stine Elise Larsenfrom PROACT provided a high-level overview of the possibilities for hardening an environment according to security standards.\nAt the beginning, references were made to CIS Benchmarks, MITRE ATT\u0026amp;CK, ISO 27001, and other measures and guidelines.\nHowever, the core of the presentation focused on so-called rogue or ghost VMs—virtual machines that cannot be listed directly via vCenter or the ESXi host itself. These VMs can be created relatively easily using the command:\nbin/vmx-x /vmfs/volumes/volname/vmname/vmname.vmx 2\u0026gt;/dev/null 0\u0026gt;/dev/null \u0026amp;\nNormally, these VMs are removed when the ESXi host is rebooted, but it was also demonstrated how they can be made persistent:\n#!/bin/sh ++group=host/vim/vmvisor/boot # Note: modify at your own risk! # Note: This script will not be run when UEFI secure boot is enabled. /bin/vmx -x /vmfs/volumes/volname/vmname/vmname.vmx 2\u0026gt;/dev/null 0\u0026gt;/dev/null \u0026amp; exit o At the end, it was also demonstrated how these VMs can be identified:\nesxcli vm process list\nTo make the creation of such VMs more difficult for attackers and to improve detection, the following measures were recommended:\nMonitor ESX for SSH enablement and logins # /var/log/shell.log /var/log/auth.log vCenter alarm for SSH enabled Use Secure Boot # prohibits/etc/rc.local.d/local.sh from running on boot Use Distributed Switches / NSX Distributed Port Groups # ehernetO.dvs.portid / ethernetO.dvs.connectionld needs valid values Automation, Flexibility, and Choice from Day O to Day 2 by Guido Barendse # After that, I attended the Dell presentation on the Dell Automation Platform.\nThe session began by introducing a new approach to infrastructure. In addition to the traditional 3-tier architecture and hyperconverged infrastructure, so-called disaggregated infrastructure was presented as a model that aims to combine the two existing approaches and bring together their advantages. Dell Technologies promotes this concept as a flexible way to separate and scale compute, storage, and networking resources independently.\nFinally, a few words were also shared about the Dell Automation Platform, although without a hands-on part and only at a very high level. The platform can be used with customizable YAML blueprints to deploy Infrastructure-as-Code-based private cloud stacks from various vendors.\nMythBUSTERS: \u0026ldquo;My Legacy Network Gear Is Fine\u0026rdquo;\u0026hellip; Until it Isn\u0026rsquo;t by Chris McCain and ComDivision CEO Yves Sandfort # In the final session of the day, Chris McCain and Yves Sandfort performed a short sketch that compared two different perspectives.\nIn this scenario, Yves represented the viewpoint that a traditional hardware firewall is sufficient for all use cases within a virtualized environment, while Chris challenged this assumption. The discussion highlighted the various capabilities and use cases of the VMware vDefend stack, demonstrating how its features work and where they provide additional value.\nThe format was engaging and made the topic accessible, particularly for an audience that is still in the earlier stages of adopting software-defined networking concepts. However, since Chris McCain’s earlier session had already covered similar aspects, so parts of this closing segment felt repetitive.\nThird Day - 19.03.2026 # From Infrastructure to Platform: Running Kubernetes and Cloud Services on VCF by Katarina Brookfield # I started the third day with a presentation by Katarina Brookfield on the vSphere Kubernetes Service.\nThe session primarily focused on the core functionality of the VKS Supervisor Cluster. Katarina demonstrated how virtual machines and vSphere Kubernetes clusters can be created within it, as well as how GitOps workflows can be integrated using Argo CD.\nThe exact resources and configurations that were deployed can be reviewed in her GitHub repository.\nVPCs in NSX / VCF 9 by Daniel Krieger # My final session of the event was presented by Daniel Krieger and focused on VPCs in VCF 9.\nThe presentation covered how VPCs can be created, the different network types available, and how east-west and north-south communication are handled within this architecture. Daniel has also published several blog posts on this topic for further reading:\nVCF9 NSX VPC Part 1 VCF9 NSX VPC Part 2 VCF9 NSX VPC Part 3 Conclusion # The VMUG Connect 2026 in Amsterdam was my first VMUG event, and it certainly fulfilled its primary goal of bringing users together and fostering connections within the community.\nHowever, from a technical perspective, I felt that more advanced topics were somehow underrepresented. Many of the sessions were geared toward beginners, and while this makes the event accessible, some of the presentations, particularly those focused on automation and Kubernetes, did not go as deep as I had hoped.\nIn conversations with other attendees, I found that this impression was shared by several participants. The pre-connect sessions, on the other hand, stood out as particularly valuable. These sessions encouraged the exchange of real-world experiences and enabled more interactive discussions. Unfortunately, there were only a limited number of them.\nOn a positive note, I appreciated the opportunity to complete hands-on labs on-site, as well as the option to take various certifications at a reduced cost.\nOverall, I would say that VMware Explore may be the more suitable event for those looking to dive deeper into technical topics.\nBut if I had to descide, I would give it another try.\n","date":"22 March 2026","externalUrl":null,"permalink":"/posts/vmug_connect_2026_amsterdam_recap/","section":"Posts","summary":"","title":"VMUG Connect 2026 in Amsterdam - Recap","type":"posts"},{"content":"","date":"22 March 2026","externalUrl":null,"permalink":"/tags/vmware/","section":"Tags","summary":"","title":"VMware","type":"tags"},{"content":"","date":"1 March 2026","externalUrl":null,"permalink":"/tags/ansible/","section":"Tags","summary":"","title":"Ansible","type":"tags"},{"content":"In this blog post, I would like to compare Ansible and Terraform in relation to Day-2 automation. I will leave out the usual use cases, as I believe that Terraform cannot be reduced solely to infrastructure provisioning and Ansible to Day-2 automation. Both tools can be used for both purposes. As mentioned, I will focus explicitly on Day-2 automation in this blog post.\nWhat questions do I want to answer here?\nWhen does it make sense to use Ansible? When does it make sense to use Terraform? Are there any limitations? Introduction # Let\u0026rsquo;s start with a brief introduction to both tools.\nAnsible # “Ansible is an open source IT automation engine that automates provisioning, configuration management, application deployment, orchestration, and many other IT processes.” – Redhat\nAnsible uses the declarative programming language YAML. In Ansible, automation processes are executed by so-called playbooks. These playbooks contain imperative tasks (which, in my opinion, makes the entire tool imperative again) consisting of modules from various collections that are used to configure your target system. These collections are comparable to class libraries and modules are comparable to methods in other programming languages.\nTerraform # “Terraform provides organizations with a single workflow to provision their cloud, private datacenter, and SaaS infrastructure and continuously manage it throughout its lifecycle.” – Hashicorp\nBased on the description, it seems that Terraform is actually rather unsuitable for Day-2 automation of systems. However, this is not the case. Many manufacturers today place a strong focus on so-called Terraform providers for their own tools. Examples include VMware, Microsoft, Google, and many large cloud providers. For this reason, Terraform can be used in a wide variety of ways today. Terraform uses HCL as a declarative programming language. This is where so-called resources and data sources come into play. Resources are used to perform new configuration steps, which are usually mapped as objects by the API. Data sources are used to extract existing data from the system to be configured. When the configuration is applied to the target system, Terraform stores its state in a so-called terraform state file. This is updated with every change and is itself versioned. A major advantage of Terraform is the combination of the provider and the tool itself in the underlying structure. Developers do not have to worry about the order in which things need to be executed so that they build on each other logically. Terraform takes care of this.\nFor example: When configuring my software-defined network in the cloud, You have to pay attention to which element is created when in imperative programming languages, because it may be that these elements are directly dependent on each other in terms of their existence. For example, if I want to create a router and an associated segment, the router must be created first, otherwise you cannot link my segment.\nUse Cases # When does it make sense to use Ansible? # Let\u0026rsquo;s assume that there is no separate Terraform provider for my tool (in the following example, a web server with API). In this case, it is still possible to execute API calls with Terraform. But does this make sense? In the following example, we want to query facts about cats from the website “catfact.ninja” via the API. The API is designed in such a way that we get a different fact with each call. Let\u0026rsquo;s start with Ansible:\n- name: API Call Use Case hosts: localhost tasks: - name: Get cat fact ansible.builtin.uri: url: \u0026#34;https://catfact.ninja/fact\u0026#34; register: response - name: Show cat fact ansible.builtin.debug: var: response.json.fact Whenever we execute the playbook, we get a new fact (as desired).\n% ansible-playbook playbook.yml PLAY [API Call Usecase] ***************************************************************************************************************************************** TASK [Get cat fact] ********************************************************************************************************************************************* ok: [localhost] TASK [Show cat fact] ******************************************************************************************************************************************** ok: [localhost] =\u0026gt; { \u0026#34;response.json.fact\u0026#34;: \u0026#34;Cats have 30 vertebrae (humans have 33 vertebrae during early development; 26 after the sacral and coccygeal regions fuse)\u0026#34; } % ansible-playbook playbook.yml PLAY [API Call Usecase] ***************************************************************************************************************************************** TASK [Get cat fact] ********************************************************************************************************************************************* ok: [localhost] TASK [Show cat fact] ******************************************************************************************************************************************** ok: [localhost] =\u0026gt; { \u0026#34;response.json.fact\u0026#34;: \u0026#34;The first cartoon cat was Felix the Cat in 1919. In 1940, Tom and Jerry starred in the first theatrical cartoon “Puss Gets the Boot.” In 1981 Andrew Lloyd Weber created the musical Cats, based on T.S. Eliot’s Old Possum’s Book of Practical Cats.\u0026#34; } But what about Terraform?\nIn the Terraform example, we use the Terracurl provider to execute the API calls. To get the response back as output, I created an Output in the HCL code.\nterraform { required_providers { terracurl = { source = \u0026#34;devops-rob/terracurl\u0026#34; } } } resource \u0026#34;terracurl_request\u0026#34; \u0026#34;cat_fact\u0026#34; { name = \u0026#34;cat fact\u0026#34; url = \u0026#34;https://catfact.ninja/fact\u0026#34; method = \u0026#34;GET\u0026#34; response_codes = [200] } output \u0026#34;response\u0026#34; { value = terracurl_request.cat_fact.response } Until a Terraform configuration is fully executed, there are several validation and simulation steps. When we execute terraform plan, we get a simulation back. It\u0026rsquo;s basically a \u0026ldquo;what if\u0026rdquo; scenario.\n% terraform plan Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: + create Terraform will perform the following actions: # terracurl_request.cat_fact will be created + resource \u0026#34;terracurl_request\u0026#34; \u0026#34;cat_fact\u0026#34; { + destroy_request_url_string = (known after apply) + destroy_retry_interval = 10 + destroy_timeout = 10 + drift_marker = (known after apply) + id = (known after apply) + method = \u0026#34;GET\u0026#34; + name = \u0026#34;cat fact\u0026#34; + request_url_string = (known after apply) + response = (known after apply) + response_codes = [ + \u0026#34;200\u0026#34;, ] + retry_interval = 10 + skip_destroy = true + skip_read = true + status_code = (known after apply) + timeout = 10 + url = \u0026#34;https://catfact.ninja/fact\u0026#34; } Plan: 1 to add, 0 to change, 0 to destroy. Changes to Outputs: + response = (known after apply) As we can see, Terraform plans to execute the API call but, logically, does not yet know the response. So we execute terraform apply and get the response:\n% terraform apply Changes to Outputs: + response = (known after apply) terracurl_request.cat_fact: Creating... terracurl_request.cat_fact: Creation complete after 0s [id=cat fact] Apply complete! Resources: 1 added, 0 changed, 0 destroyed. Outputs: response = \u0026#34;{\\\u0026#34;fact\\\u0026#34;:\\\u0026#34;In Ancient Egypt, when a person\u0026#39;s house cat passed away, the owner would shave their eyebrows to reflect their grief.\\\u0026#34;,\\\u0026#34;length\\\u0026#34;:117}\u0026#34; Everything is functional so far. One would think that if terraform apply is executed again, we will get a different output. Let\u0026rsquo;s test it:\n% terraform apply -auto-approve terracurl_request.cat_fact: Refreshing state... [id=cat fact] No changes. Your infrastructure matches the configuration. Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed. Apply complete! Resources: 0 added, 0 changed, 0 destroyed. Outputs: response = \u0026#34;{\\\u0026#34;fact\\\u0026#34;:\\\u0026#34;In Ancient Egypt, when a person\u0026#39;s house cat passed away, the owner would shave their eyebrows to reflect their grief.\\\u0026#34;,\\\u0026#34;length\\\u0026#34;:117}\u0026#34; As we can see, nothing has changed. But why is that? When we execute the command terraform plan, Terraform compares the desired state of the configuration with the target system and the so-called Terraform state file. However, because our API call cannot be simulated by the provider, Terraform thinks that nothing has changed and therefore nothing will change for the API call either. This also answers the question: What are the limitations of Terraform?\nWhen does it make sense to use Terraform? # Let\u0026rsquo;s assume we have a suitable Terraform provider. What are the advantages? Let\u0026rsquo;s look at an example where we want to create a simple text file and then a second one with the sha256 checksum of the first file. In Ansible, we need the following three tasks for this:\n- name: Create files hosts: localhost tasks: - name: Create file foo copy: dest: \u0026#34;foo.bar\u0026#34; content: \u0026#34;foo!\u0026#34; - name: Get sha256 sum of foo.bar stat: path: foo.bar checksum_algorithm: sha256 get_checksum: yes register: foo_bar_sha256 - name: Create file bar copy: dest: bar.foo content: \u0026#34;{{ foo_bar_sha256.stat.checksum }}\u0026#34; How can we do this imperative process in Terraform? In Terraform, we only specify the two files with a link:\nresource \u0026#34;local_file\u0026#34; \u0026#34;bar\u0026#34; { content = local_file.foo.content_sha256 filename = \u0026#34;${path.module}/bar.foo\u0026#34; } resource \u0026#34;local_file\u0026#34; \u0026#34;foo\u0026#34; { content = \u0026#34;foo!\u0026#34; filename = \u0026#34;${path.module}/foo.bar\u0026#34; } Terraform automatically recognizes that the files are dependent on each other and determines the order itself.\nWe can track these dependencies with the command terraform graph:\n% terraform graph digraph G { rankdir = \u0026#34;RL\u0026#34;; node [shape = rect, fontname = \u0026#34;sans-serif\u0026#34;]; \u0026#34;local_file.bar\u0026#34; [label=\u0026#34;local_file.bar\u0026#34;]; \u0026#34;local_file.foo\u0026#34; [label=\u0026#34;local_file.foo\u0026#34;]; \u0026#34;local_file.bar\u0026#34; -\u0026gt; \u0026#34;local_file.foo\u0026#34;; } Conclusion – What can we learn from this? # When it comes to flexibility, Ansible is usually the better choice. It allows you to achieve vendor independence, and if there are suitable collections available, so much the better. However, if there is a vendor-driven Terraform provider, this often saves a lot of work. Terraform also offers major advantages when it comes to versioning the state file and tracking changes. Ultimately, both are very good tools that can be combined excellently with CI/CD tools such as GitLab CI/CD, Ansible Automation Platform, or GitHub Actions.\n","date":"1 March 2026","externalUrl":null,"permalink":"/posts/ansible_vs_terraform/","section":"Posts","summary":"","title":"Comparing Ansible with Terraform - Day-2 Automation","type":"posts"},{"content":"","date":"1 March 2026","externalUrl":null,"permalink":"/tags/gitops/","section":"Tags","summary":"","title":"GitOps","type":"tags"},{"content":"","date":"1 March 2026","externalUrl":null,"permalink":"/tags/terraform/","section":"Tags","summary":"","title":"Terraform","type":"tags"},{"content":" Get started with kubernetes # I recently took the CKA exam and achieved a score of 98%. In this post, I want to share some advice on how you can prepare for the certification, whether you already have hands-on experience or are just getting started.\nLet me begin with my own situation before I started preparing for the exam. I began working with Kubernetes in May 2025 by building my first one-node cluster, which quickly evolved into a two-node Raspberry Pi setup. I started my journey with k3s and later switched to a kubeadm-based installation. This leads to my first recommendation for beginners: set up a cluster of your own and start deploying real, meaningful workloads. It doesn’t matter what you deploy. What matters is getting familiar with interacting with the Kube-API-Server directly.\nIn August 2025 I decided to take my preparations more seriously and purchased a Udemy course from Mumshad Mannambeth: Certified Kubernetes Administrator (CKA) with Practice Tests\nThis turned out to be the best decision for learning the fundamentals and everything required for the CKA exam. The course is hands-on, covers real-world scenarios, provides online labs to practise the material, offers mock exams that mirror the real test, and includes all the updated topics for the 2025 CKA exam anbd I really think it will also cover any aditional topic for future exam changes.\nWhat you get after purchasing the exam # After finishing this course with all the mock exams, your Kubernetes knowledge foundation should be very solid. From this point, you can (and should) proceed to purchasing the actual exam.\nWhat you will get with your purchase:\nTwo exam simulators on killer.sh Access to the free CKA preparation platform killercoda one free retake Hardware and other additional requirements, including optional testing steps Exam preparation # Now the real exam preparation begins, and you need to strengthen the topics you have already learned from Mumshad’s Udemy course. My recommendation is to start by working through all killercoda scenarios. For additional practice, it can also be useful to explore the free two-node cluster playgrounds.\nAfter completing the killercoda￼ scenarios, you should be well prepared for the two killer.sh exam simulators. These simulators are extremely valuable for getting used to working under time pressure and for familiarizing yourself with the actual exam environment, which is almost identical. Each simulator environment is available for 36 hours.\nIf you score around 70–80%, that should be more than enough to pass the real exam, which requires 66%. If not, use as much of the 36-hour window as possible (you can safely shut down your computer in between), write down the topics you struggled with, and revisit them using the relevant killercoda scenarios. For the second killer.sh simulator, you should aim to reach at least 70% on your first attempt.\nFinal tips # Before taking the actual CKA exam, I want to share one final, essential tip. There are GitHub repositories online that maintain question sets very similar to those found in the exam. I won’t provide links to them, but with a bit of research, you can find references to these repositories on forums such as Reddit.\nAfter completing all the previous steps, you are finally ready to take the exam. Schedule your exam at least two weeks in advance, and make sure you meet all system and room requirements. Good luck and all the best! 🍀\n","date":"23 December 2025","externalUrl":null,"permalink":"/posts/cka_exam_prep/","section":"Posts","summary":"","title":"How to crack the Certified Kubernetes Administrator (CKA) exam in 2025/2026","type":"posts"},{"content":"Today’s blog post will focus on how to deploy a vSphere Kubernetes Service Supervisor Cluster using Infrastructure-as-Code (IaC) tools.\nFirst, we will briefly compare Terraform and Ansible in this context, and then we will explore the actual automation workflow implemented with Ansible.\nTerraform Considerations # Terraform is widely used for declarative infrastructure management, but certain limitations exist when working with vSphere Supervisor Clusters.\nFor example, the Tier-0 Gateway, a required parameter for a Supervisor Cluster, is not explicitly configurable in the Terraform provider. Testing environments with multiple Tier-0 Gateways showed that Terraform could not determine which gateway to use, causing deployment failures.\nThis highlights that while Terraform is powerful for many scenarios, it may not be the best choice when fine-grained control over vCenter or NSX configurations is required.\nImplementing Automation with Ansible # To overcome these limitations, the deployment workflow was implemented with Ansible, which allows direct API interactions and full control over the Supervisor Cluster creation process.\nBefore creating playbooks, API calls against vCenter were tested using the vCenter Developer Center, confirming reliable communication with the vCenter API and providing a solid foundation for automation.\nAnsible Playbooks # Two key Ansible playbooks were developed:\nOne for deploying the Supervisor Cluster - createSV.yaml Another for creating the Namespaces within that cluster - createNS.yaml (loops task_seq_ns.yaml) Both Playbooks collect their data from a supervisor manifest file: supervisor.yaml\nsupervisor.yaml # apiVersion: v1 kind: vSphereSupervisorCluster metadata: name: \u0026#39;VKS-Cluster-01\u0026#39; vsphere_url: \u0026#39;https://vcenter.example.com\u0026#39; vsphere_username: \u0026#39;\u0026lt;base64 encoded vsphere user\u0026gt;\u0026#39; vsphere_password: \u0026#39;\u0026lt;base64 encoded vsphere password\u0026gt;\u0026#39; spec: clusterName: \u0026#39;cluster-01\u0026#39; storagePolicyName: \u0026#39;Storage Policy\u0026#39; tierZeroName: \u0026#39;example-tier-0\u0026#39; sizeHint: \u0026#39;SMALL\u0026#39; networks: masterManagementNetwork: addressRange: subnetMask: \u0026#39;255.255.255.0\u0026#39; startingAddress: \u0026#39;10.0.0.10\u0026#39; gateway: \u0026#39;10.0.0.1\u0026#39; addressCount: 5 network: \u0026#39;network-1\u0026#39; pods: address: \u0026#39;10.60.0.0\u0026#39; prefix: \u0026#39;20\u0026#39; services: address: \u0026#39;10.70.0.0\u0026#39; prefix: \u0026#39;24\u0026#39; ingress: address: \u0026#39;10.80.0.0\u0026#39; prefix: \u0026#39;24\u0026#39; egress: address: \u0026#39;10.90.0.0\u0026#39; prefix: \u0026#39;24\u0026#39; masterNTPServers: - \u0026#39;10.0.0.1\u0026#39; masterDNS: - \u0026#39;10.0.0.1\u0026#39; workerDNS: - \u0026#39;10.0.0.1\u0026#39; networkProvider: \u0026#39;NSXT_CONTAINER_PLUGIN\u0026#39; namespaces: - name: \u0026#39;namespace-1\u0026#39; description: \u0026#39;ansible-created-namespeace\u0026#39; type: \u0026#39;USER\u0026#39; #USER or GROUP user: \u0026#39;user-1\u0026#39; domain: \u0026#39;vsphere.local\u0026#39; role: \u0026#39;EDIT\u0026#39; #EDIT or VIEW storageLimit: \u0026#39;10240\u0026#39; - name: \u0026#39;namespace-2\u0026#39; description: \u0026#39;ansible-created-namespeace\u0026#39; type: \u0026#39;user-2\u0026#39; user: \u0026#39;administrator\u0026#39; domain: \u0026#39;vsphere.local\u0026#39; role: \u0026#39;EDIT\u0026#39; #EDIT or VIEW storageLimit: \u0026#39;10240\u0026#39; - name: \u0026#39;namespace-3\u0026#39; description: \u0026#39;ansible-created-namespeace\u0026#39; type: \u0026#39;USER\u0026#39; #USER or GROUP user: \u0026#39;user-3\u0026#39; domain: \u0026#39;vsphere.local\u0026#39; role: \u0026#39;EDIT\u0026#39; #EDIT or VIEW storageLimit: \u0026#39;10240\u0026#39; createSV.yaml # - name: Bootstrap vSphere Supervisor Cluster hosts: localhost vars_files: - supervisor.yaml tasks: - name: Create vSphere Session ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/rest/com/vmware/cis/session\u0026#39; method: POST url_username: \u0026#39;{{ metadata.vsphere_username | b64decode }}\u0026#39; url_password: \u0026#39;{{ metadata.vsphere_password | b64decode }}\u0026#39; force_basic_auth: true validate_certs: false register: session - name: Show if session failed ansible.builtin.fail: msg: \u0026#39;Login failed. Please provide the correct username + password\u0026#39; when: session.status != 200 - name: Show status code ansible.builtin.debug: msg: - \u0026#39;Status_Code: {{ session.status }}\u0026#39; - \u0026#39;Session_ID: {{ session.json.value }}\u0026#39; verbosity: 2 - name: Get Cluster ID ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/rest/vcenter/cluster?filter.names={{ spec.clusterName }}\u0026#39; method: GET validate_certs: false headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; register: vsphere_cluster - name: Set fact vSphere Cluster ID ansible.builtin.set_fact: vsphere_cluster_id: \u0026#39;{{ vsphere_cluster.json.value[0].cluster }}\u0026#39; - name: Get Storage Policies ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/rest/vcenter/storage/policies\u0026#39; method: GET validate_certs: false headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; register: vsphere_storage_policies - name: Show Storage Policies ansible.builtin.debug: var: vsphere_storage_policies verbosity: 2 - name: Get Storage Policy ID ansible.builtin.set_fact: vsphere_storage_policy_id: \u0026#34;{{ (vsphere_storage_policies.json.value | selectattr(\u0026#39;name\u0026#39;, \u0026#39;equalto\u0026#39;, spec.storagePolicyName ))[0].policy }}\u0026#34; - name: Show vSphere Storage Policy ID ansible.builtin.debug: var: vsphere_storage_policy_id verbosity: 2 - name: Get Virtual Distributed Switch ID ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/api/vcenter/namespace-management/distributed-switch-compatibility?cluster={{ vsphere_cluster_id }}\u0026amp;compatible=true\u0026#39; method: GET validate_certs: false headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; register: vsphere_vds - name: Show Virtual Distributed Switch ID ansible.builtin.debug: var: vsphere_vds.json[0].distributed_switch verbosity: 2 - name: Set fact Virtual Distributed Switch ID ansible.builtin.set_fact: vsphere_vds_id: \u0026#39;{{ vsphere_vds.json[0].distributed_switch }}\u0026#39; - name: Get NSX Edge Cluster ID ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/api/vcenter/namespace-management/edge-cluster-compatibility?cluster={{ vsphere_cluster_id }}\u0026amp;compatible=true\u0026amp;distributed_switch={{ vsphere_vds_id | urlencode }}\u0026#39; method: GET validate_certs: false headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; register: nsx_edge_cluster - name: Show NSX Edge Cluster ID ansible.builtin.debug: var: nsx_edge_cluster.json[0].edge_cluster verbosity: 2 - name: Set fact NSX Edge Cluster ID ansible.builtin.set_fact: nsx_edge_cluster_id: \u0026#39;{{ nsx_edge_cluster.json[0].edge_cluster }}\u0026#39; - name: Get Master Network Portgroup ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/rest/vcenter/network?filter.names={{ spec.networks.masterManagementNetwork.network }}\u0026#39; method: GET validate_certs: false headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; register: vsphere_master_network_portgroup_response - name: Show Master Network Portgroup ID ansible.builtin.debug: var: vsphere_master_network_portgroup_response.json.value[0].network verbosity: 2 - name: Set fact Master Network Portgroup ID ansible.builtin.set_fact: vsphere_master_network_portgroup_id: \u0026#39;{{ vsphere_master_network_portgroup_response.json.value[0].network }}\u0026#39; - name: Get Tier-0 ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/api/vcenter/namespace-management/nsx-tier0-gateways?distributed_switch={{ vsphere_vds_id | urlencode }}\u0026#39; method: GET validate_certs: false headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; register: nsx_tier_zero_gateways - name: Set fact Tier-0 Gateway ansible.builtin.set_fact: nsx_tier_zero_gateway_id: \u0026#34;{{ (nsx_tier_zero_gateways.json | selectattr(\u0026#39;display_name\u0026#39;, \u0026#39;equalto\u0026#39;, spec.tierZeroName) | first).tier0_gateway }}\u0026#34; - name: Show Tier-0 Gateway ID ansible.builtin.debug: var: nsx_tier_zero_gateway_id verbosity: 2 - name: Get Supervisor Cluster ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/api/vcenter/namespace-management/clusters/{{ vsphere_cluster_id }}\u0026#39; method: GET validate_certs: false status_code: - 200 - 404 headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; register: supervisor_cluster - name: Show Message if Supervisor Cluster already exists ansible.builtin.debug: msg: \u0026#39;Supervisor Cluster already exists.\u0026#39; when: supervisor_cluster.status == 200 - name: Create Supervisor Cluster ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/api/vcenter/namespace-management/clusters/{{ vsphere_cluster_id }}?action=enable\u0026#39; method: POST validate_certs: false status_code: - 200 - 204 headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; body_format: json body: image_storage: storage_policy: \u0026#39;{{ vsphere_storage_policy_id }}\u0026#39; ncp_cluster_network_spec: nsx_edge_cluster: \u0026#39;{{ nsx_edge_cluster_id }}\u0026#39; nsx_tier0_gateway: \u0026#39;{{ nsx_tier_zero_gateway_id }}\u0026#39; pod_cidrs: - address: \u0026#39;{{ spec.networks.pods.address }}\u0026#39; prefix: \u0026#39;{{ spec.networks.pods.prefix }}\u0026#39; egress_cidrs: - address: \u0026#39;{{ spec.networks.egress.address }}\u0026#39; prefix: \u0026#39;{{ spec.networks.egress.prefix }}\u0026#39; ingress_cidrs: - address: \u0026#39;{{ spec.networks.ingress.address }}\u0026#39; prefix: \u0026#39;{{ spec.networks.ingress.prefix }}\u0026#39; cluster_distributed_switch: \u0026#39;{{ vsphere_vds_id }}\u0026#39; master_management_network: mode: \u0026#39;STATICRANGE\u0026#39; network: \u0026#39;{{ vsphere_master_network_portgroup_id }}\u0026#39; address_range: subnet_mask: \u0026#39;{{ spec.networks.masterManagementNetwork.addressRange.subnetMask }}\u0026#39; starting_address: \u0026#39;{{ spec.networks.masterManagementNetwork.addressRange.startingAddress }}\u0026#39; gateway: \u0026#39;{{ spec.networks.masterManagementNetwork.addressRange.gateway }}\u0026#39; address_count: \u0026#39;{{ spec.networks.masterManagementNetwork.addressRange.addressCount }}\u0026#39; master_NTP_servers: \u0026#39;{{ spec.networks.masterNTPServers }}\u0026#39; ephemeral_storage_policy: \u0026#39;{{ vsphere_storage_policy_id }}\u0026#39; service_cidr: address: \u0026#39;{{ spec.networks.services.address }}\u0026#39; prefix: \u0026#39;{{ spec.networks.services.prefix }}\u0026#39; size_hint: \u0026#39;{{ spec.sizeHint }}\u0026#39; master_DNS: \u0026#39;{{ spec.networks.masterDNS }}\u0026#39; worker_DNS: \u0026#39;{{ spec.networks.workerDNS }}\u0026#39; network_provider: \u0026#39;{{ spec.networks.networkProvider }}\u0026#39; master_storage_policy: \u0026#39;{{ vsphere_storage_policy_id }}\u0026#39; when: supervisor_cluster.status != 200 - name: Drop vSphere session ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/rest/com/vmware/cis/session\u0026#39; method: DELETE url_username: \u0026#39;{{ metadata.vsphere_username | b64decode }}\u0026#39; url_password: \u0026#39;{{ metadata.vsphere_password | b64decode }}\u0026#39; force_basic_auth: true validate_certs: false status_code: - 200 - 401 createNS.yaml # - name: Create Namespaces hosts: localhost vars_files: - supervisor.yaml tasks: - name: Create vSphere Session ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/rest/com/vmware/cis/session\u0026#39; method: POST url_username: \u0026#39;{{ metadata.vsphere_username | b64decode }}\u0026#39; url_password: \u0026#39;{{ metadata.vsphere_password | b64decode }}\u0026#39; force_basic_auth: true validate_certs: false register: session - name: Show if session failed ansible.builtin.fail: msg: \u0026#39;Login failed. Please provide the correct username + password\u0026#39; when: session.status != 200 - name: Show status code ansible.builtin.debug: msg: - \u0026#39;Status_Code: {{ session.status }}\u0026#39; - \u0026#39;Session_ID: {{ session.json.value }}\u0026#39; verbosity: 2 - name: Get Cluster ID ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/rest/vcenter/cluster?filter.names={{ spec.clusterName }}\u0026#39; method: GET validate_certs: false headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; register: vsphere_cluster - name: Set fact vSphere Cluster ID ansible.builtin.set_fact: vsphere_cluster_id: \u0026#39;{{ vsphere_cluster.json.value[0].cluster }}\u0026#39; - name: Get Storage Policies ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/rest/vcenter/storage/policies\u0026#39; method: GET validate_certs: false headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; register: vsphere_storage_policies - name: Show Storage Policies ansible.builtin.debug: var: vsphere_storage_policies verbosity: 2 - name: Set fact Storage Policy ID ansible.builtin.set_fact: vsphere_storage_policy_id: \u0026#34;{{ (vsphere_storage_policies.json.value | selectattr(\u0026#39;name\u0026#39;, \u0026#39;equalto\u0026#39;, spec.storagePolicyName ))[0].policy }}\u0026#34; - name: Show vSphere Storage Policy ID ansible.builtin.debug: var: vsphere_storage_policy_id verbosity: 2 - name: Get Supervisor Cluster ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/api/vcenter/namespace-management/clusters/{{ vsphere_cluster_id }}\u0026#39; method: GET validate_certs: false headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; register: supervisor_clusters - name: Loop Namespaces include_tasks: task_seq_ns.yaml loop: \u0026#39;{{ spec.namespaces }}\u0026#39; loop_control: loop_var: item task_seq_ns.yaml # - name: Get Namespace ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/api/vcenter/namespaces/instances/{{ item.name }}\u0026#39; method: GET validate_certs: false status_code: - 200 - 404 headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; register: namespace - name: Show Message if Namespace already exists ansible.builtin.debug: msg: \u0026#39;Namespace: {{ item.name }} already exisiting.\u0026#39; when: namespace.status == 200 - name: Create Namespace ansible.builtin.uri: url: \u0026#39;{{ metadata.vsphere_url }}/api/vcenter/namespaces/instances\u0026#39; method: POST validate_certs: false status_code: - 200 - 204 headers: vmware-api-session-id: \u0026#39;{{ session.json.value }}\u0026#39; body_format: json body: cluster: \u0026#39;{{ vsphere_cluster_id }}\u0026#39; namespace: \u0026#39;{{ item.name }}\u0026#39; description: \u0026#39;{{ item.description }}\u0026#39; access_list: - role: \u0026#39;{{ item.role }}\u0026#39; subject_type: \u0026#39;{{ item.type }}\u0026#39; subject: \u0026#39;{{ item.user }}\u0026#39; domain: \u0026#39;{{ item.domain }}\u0026#39; storage_specs: - limit: \u0026#39;{{ item.storageLimit }}\u0026#39; policy: \u0026#39;{{ vsphere_storage_policy_id }}\u0026#39; when: namespace.status != 200 These playbooks were fully tested and verified to work as expected. This approach allows for repeatable and consistent deployments of Supervisor Clusters and Namespaces, entirely managed through code. You can find the official repository on github: vsphere-supervisor-as-code.\nConclusion # Using Ansible to automate the deployment of a vSphere Supervisor Cluster provides a flexible, reliable, and reproducible solution. While Terraform remains a strong tool for declarative infrastructure, its current provider limitations make Ansible the better choice for scenarios that require direct API control and precise configuration management.\nThis automation workflow ensures that Supervisor Clusters can be deployed consistently, laying the groundwork for fully codified management of Kubernetes infrastructure on vSphere.\n","date":"13 October 2025","externalUrl":null,"permalink":"/posts/deploying_a_vks_supervisor_with_ansible/","section":"Posts","summary":"","title":"Deploying a vSphere Kubernetes Service (VKS) Supervisor with Ansible","type":"posts"},{"content":"I recently purchased a single domain from ionos.com and needed to configure an additional ClusterIssuer in my Kubernetes cluster alongside my existing Cloudflare ClusterIssuer, since IONOS uses a different API endpoint. It took me a while to find suitable documentation for this, as most guides focus on IONOS Cloud rather than IONOS DNS. So this Blogpost will only focus on the cert-manager part.\nInstall cert-manager via helm # Add the helm repository\nhelm repo add jetstack https://charts.jetstack.io --force-update\nInstall cert-manager\nhelm install \\ cert-manager jetstack/cert-manager \\ --namespace cert-manager \\ --create-namespace \\ --version v1.18.2 \\ --set crds.enabled=true What is a Cluster Issuer? # After installing cert-manager, the next step is to create a ClusterIssuer. A ClusterIssuer in Kubernetes is a cluster-wide resource managed by cert-manager that defines how TLS certificates are requested and issued. It includes the configuration for the certificate authority or ACME provider (such as Let’s Encrypt) and specifies the challenge type (e.g., DNS-01 or HTTP-01).\nAn ACME challenge is the process used by certificate authorities to verify that you control the requested domain before issuing an SSL/TLS certificate. For DNS-01 challenges, this is typically done by creating a specific TXT record in your domain’s DNS zone—a task cert-manager can handle automatically using the IONOS DNS API.\nCreate an IONOS API Token # To allow cert-manager to create these DNS records, we need an API token from IONOS.\nLog in to your IONOS account. Navigate to Domain \u0026amp; SSL → Open API Portal. Create a new API token. (I will delete mine after publishing this post) Copy your public prefix and secret. We will use it in the next step when creating the Kubernetes Secret for cert-manager.\nCreate the secret and ClusterIssuer # First, we need to create a new file secret.yaml with the following content.\napiVersion: v1 stringData: IONOS_PUBLIC_PREFIX: 7a3e4d1e81c44eae8bd177f945047aba # paste your public prefix IONOS_SECRET: IfXEjhXLfzzGihvFAJ8aTwGTj6Pu2TgY4Po7Mj8YaBHJxHohpv7QkXCMArou79h1zRbf7X5-R42RnTPXLJjszg # paste your secret kind: Secret metadata: name: ionos-secret namespace: cert-manager type: Opaque Create your secret:\nkubectl apply -f secret.yaml\nNow, we can create the ClusterIssuer file issuer.yaml\napiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: name: letsencrypt-ionos spec: acme: server: https://acme-v02.api.letsencrypt.org/directory email: \u0026lt;your_mail@example.com\u0026gt; privateKeySecretRef: name: letsencrypt-ionos solvers: - dns01: webhook: groupName: acme.fabmade.de solverName: ionos config: apiUrl: https://api.hosting.ionos.com/dns/v1 publicKeySecretRef: key: IONOS_PUBLIC_PREFIX name: ionos-secret secretKeySecretRef: key: IONOS_SECRET name: ionos-secret Now you can create secrets, as in this example\napiVersion: cert-manager.io/v1 kind: Certificate metadata: name: example-certificate namespace: example spec: dnsNames: - \u0026#39;your.domain\u0026#39; issuerRef: name: letsencrypt-ionos kind: ClusterIssuer secretName: example-secret Have fun trying it out!\n","date":"26 July 2025","externalUrl":null,"permalink":"/posts/cluster_issuer/","section":"Posts","summary":"","title":"Configuring a ClusterIssuer for IONOS Domains","type":"posts"},{"content":"","date":"25 July 2025","externalUrl":null,"permalink":"/tags/ci/cd/","section":"Tags","summary":"","title":"CI/CD","type":"tags"},{"content":"Finding the fastest way to publish static Hugo websites isn’t always straightforward.\nAfter finishing your site, you’ll probably ask yourself: What’s next? and How do I deploy it easily to production?\nIn this article, I’ll share a simple approach to build, publish, and prepare your site for containerized environments like Docker or Kubernetes—just like the website you’re reading right now.\nSo, let’s say your site is ready and you already connected a GitHub Repository. From a DevOps perspective, there are many ways to bring it to production. Here, we’ll focus on a clean workflow using GitHub, GitHub Actions, and Docker.\nYour Repository should look like this:\nDockerfile # As you can see, the website hasn’t been built yet, so there’s no public folder.\nHowever, we know that after the automatic build process, a public folder will be generated. This means we can already prepare the Docker image. In this example, we’re using Nginx as the web server, but feel free to use any other tool that fits your needs.\nCreate a Dockerfile in the root of your repository:\ntouch Dockerfile vim Dockerfile # or nano Then, paste the following content:\nFROM nginx:alpine COPY public /usr/share/nginx/html EXPOSE 80 Make sure to commit your changes:\ngit add Dockerfile git commit -m \u0026#34;feat: added Dockerfile\u0026#34; GitHub Workflow # Our CI pipeline will consist of just a few steps:\nFirst, we’ll build the Hugo site. Then, we’ll use the Dockerfile to package it into an image and push it to a container registry.\nIn this example, we’re using Docker Hub, so anyone can try it out easily. Of course, you can replace it with any other registry you prefer.\nFirst, create the .github/workflows folder to store your pipeline files:\nmkdir -p .github/workflows Then create the workflow file:\ncd .github/workflows touch hugo.yml vim hugo.yml Paste the following content:\nname: Build and Push Hugo Site to Docker Hub env: IMAGE_NAME: \u0026lt;your_image_name\u0026gt; on: push: branches: [\u0026#34;master\u0026#34;] workflow_dispatch: jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: submodules: recursive - name: Install Hugo run: | HUGO_VERSION=0.128.0 wget -O hugo.deb https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_extended_${HUGO_VERSION}_linux-amd64.deb sudo dpkg -i hugo.deb - name: Install Dart Sass run: sudo snap install dart-sass - name: Build Hugo site run: hugo --minify --baseURL \u0026#34;/\u0026#34; - name: Install cosign uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 with: cosign-release: \u0026#39;v2.2.4\u0026#39; - name: Set up Docker Buildx uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Extract Docker metadata id: meta uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 with: images: docker.io/${{ env.IMAGE_NAME }} - name: Build and push Docker image id: build-and-push uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != \u0026#39;pull_request\u0026#39; }} tags: | docker.io/${{ env.IMAGE_NAME }}:latest docker.io/${{ env.IMAGE_NAME }}:${{ github.sha }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max Finally, replace the placeholder with your own image name, for example:\nenv: IMAGE_NAME: user123/hugo-static-website Before pushing, note that the workflow uses two secrets:\nDOCKER_USERNAME and DOCKER_PASSWORD.\nYou need to configure these secrets in your GitHub repository:\nGo to Settings → Secrets and variables → Actions. Under Repository secrets, add: DOCKER_USERNAME → Your Docker Hub username DOCKER_PASSWORD → Your Docker Hub password or personal access token After completing this setup, push your changes:\ngit add . git commit -m \u0026#34;ci: added hugo pipeline\u0026#34; git push Docker Hub # Now, you should find your new image on Docker Hub:\nRun the website on your localhost with:\ndocker run -d -p 80:80 \u0026lt;user/image:tag\u0026gt; # example: user123/hugo-static-website:latest Now, you can visit your website on http://localhost\nMake your repository private on Docker Hub # If you don\u0026rsquo;t host an image registry by yourself, you could also make your docker image repository private to make the website unavailable for other docker hub users.\nTo achive this, Docker Hub go to manage repositories and choose your repository. On Settings you will find the Visibility settings. Change the visbility to private.\n","date":"25 July 2025","externalUrl":null,"permalink":"/posts/hugo-static-website-deployment/","section":"Posts","summary":"","title":"From Push to Production: Hugo Static Website Deployment","type":"posts"},{"content":"","date":"25 July 2025","externalUrl":null,"permalink":"/tags/github-actions/","section":"Tags","summary":"","title":"GitHub Actions","type":"tags"},{"content":"","date":"25 July 2025","externalUrl":null,"permalink":"/tags/hugo/","section":"Tags","summary":"","title":"Hugo","type":"tags"},{"content":"This project will be a short documentation of how I built my small 2-node Raspberry Pi 5 k3s cluster on a small budget with a minimal redundant setup.\nMy main goal is to get familiar with Kubernetes fundamentals like objects, networking, and storage. I believe that there are many people out there who want the same, which is why I’m sharing this with you. This documentation is a mix of my personal decisions — what I did and why — and a step-by-step guide for you.\nRight now, this is what the cluster looks like:\nAs you can see, there are already three slots for an additional Raspberry Pi, which I plan to add once I’m more familiar with the current setup.\nList of Hardware Components # Name Quantity Link Raspberry Pi 5, 8GB RAM 2 berrybase.de GeeekPi P33 M.2 NVME M-Key PoE+ Hat 2 amazon.de Fanxiang M.2 SSD 512GB 2 amazon.de 2U DAP rack panel 1 musicstore.de TP-Link TL-SG1005P PoE Switch 1 amazon.de SanDisk 32 GB micro SD card 1 amazon.de Total costs: 395€ / 448$\nPrintables # Name Quantity Link TL-SG1005P Rack Mount 1 berrybase.de Raspberry Pi 5 Rack-Mount 2 printables.com The Setup # First, after assembling the components for the Raspberry Pis, I had to create a bootable SD card with Ubuntu Desktop on my PC to boot the Raspberry Pis. Later, I’ll need Ubuntu Desktop to make the NVMe drives bootable for the Raspberry Pis.\nTo make the SD card and NVMe\u0026rsquo;s bootable, I use the Raspberry Pi Imager.\nAfter creating the bootable SD card, insert it into the first Raspberry Pi and power it on. Once the Pi has booted, download the Raspberry Pi Imager again – this time directly on the Pi.\nThen use it to flash Ubuntu Server onto the NVMe drive, which will later be used to run the k3s cluster. This is also the point where you should set the hostname for the Pi. I will skip this part in the documentation. Repeat this step for all other Raspberry Pis.\nConfigure static IPs # If you want your Raspberry Pis to use static IPs instead of DHCP (for example, because you don’t have a DNS server to resolve their hostnames for SSH access), you should adjust the network configuration accordingly:\n# 1. change dhcpcd.conf sudo nano /etc/dhcpcd.conf # 2. setup static IP interface eth0 static ip_address=192.168.1.50/24 static routers=192.168.1.1 # 3. control + X to save changes Repeat this step for all other Raspberry Pis.\nInstalling k3s # Why k3s? (Talos vs k3s) # Before setting up the cluster, I considered a few lightweight options for the setup. Talos OS and k3s made the shortlist.\nUnfortunately, Talos OS currently doesn’t support the Raspberry Pi 5, and there’s no community workaround available either — so I went with k3s. However, a major advantage of k3s is that the nodes can also be used later for purposes other than Kubernetes. For example, for automation, Docker and so on.\nInstall the master node # First, choose one of your Raspberry Pis to act as the master node (now called the control plane). It will run the Kubernetes API server and manage the cluster.\nThen, on that Pi, run the following:\n# Update repositories and upgrade existing packages sudo apt update \u0026amp;\u0026amp; sudo apt upgrade -y # Install curl sudo apt install curl -y # Install k3s curl -sfL https://get.k3s.io | sh - # Copy the k3s token (you’ll need it for the agent nodes) cat /var/lib/rancher/k3s/server/node-token This sets up your master node.\nThe k3s service is now running and managed by systemd. You can check the status with:\nsudo systemctl status k3s A master node (now called control plane) manages the Kubernetes cluster – it schedules workloads, maintains the desired state, and handles scaling. An agent node (or worker node) runs the actual applications (pods) and reports back to the control plane.\nInstall the agent node(s) # Next, install the agent nodes using the IP address of the master node and the token you copied earlier. Do this on each of your other Raspberry Pis:\n# Update repositories and upgrade existing packages sudo apt update \u0026amp;\u0026amp; sudo apt upgrade -y # Install curl sudo apt install curl -y # Install k3s and join the cluster (replace \u0026lt;MASTER_IP\u0026gt; and \u0026lt;NODE_TOKEN\u0026gt;) curl -sfL https://get.k3s.io | K3S_URL=https://\u0026lt;MASTER_IP\u0026gt;:6443 K3S_TOKEN=\u0026lt;NODE_TOKEN\u0026gt; sh - Make sure your master node is reachable from the agent nodes on port 6443. If you’re using a firewall or NAT, ensure this port is open and accessible.\nOnce joined, you can check the cluster status on the master node with:\nkubectl get nodes Your cluster should look like this:\nSetup remote control (only for MacOS users) # To manage your Raspberry Pi k3s cluster from your Mac, you first have to install kubectl with homebrew:\nbrew install kubectl You can verify the installation with:\nkubectl version --client After installing kubectl you have to copy the kubeconfig from your master-node to your mac. The config is located at (on your Pi): /etc/rancher/k3s/k3s.yaml Make sure the ~/.kube directory and config file exists on your mac; if not, create it:\nmkdir -p ~/.kube # create the config file on your mac touch ~/.kube/config Copy the content of the /etc/rancher/k3s/k3s.yaml:\nnano ~/.kube/config # Replace Server IP with the actual IP address of your master Raspberry Pi: # Find the line: server: https://127.0.0.1:6443 # Change it to: server: https://\u0026lt;MASTER_IP\u0026gt;:6443 # Save and close the file Now you can test your connection with:\nkubectl get nodes You\u0026rsquo;re now ready to deploy things in Kubernetes!\nStorage # Once you become more familiar with Kubernetes, you’ll quickly encounter the topic of persistent storage. The challenge with the current cluster setup is that a pod can only claim storage from the local disk of the node it’s running on. If the scheduler moves the pod to a different node (e.g., due to a node failure), the pod may no longer have access to its previously claimed storage.\nWhile it’s possible to use NFS as a shared storage solution, in this project we’re focusing solely on the Raspberry Pi cluster and assume that no external storage is available.\nAccording to the CNCF landscape, there are several options to provide redundant, highly available storage within a Kubernetes cluster. Two popular choices for homelab setups like this are Rook Ceph and Longhorn.\nRook Ceph is a powerful, production-grade distributed storage system, but it requires more resources and is relatively complex to set up and maintain — especially on low-power devices like Raspberry Pis.\nLonghorn, on the other hand, is lightweight, easy to install, and specifically designed for cloud-native environments. It’s well-suited for small clusters and works reliably on ARM-based devices.\nThat\u0026rsquo;s why I went with Longhorn for this setup.\nDeploying Longhorn # According to the k3s-Longhorn documentation, you can install Longhorn with the following command:\nkubectl apply -f https://raw.githubusercontent.com/longhorn/longhorn/v1.6.0/deploy/longhorn.yaml Longhorn will be installed in the namespace longhorn-system.\nYou’re now ready to create volumes using Longhorn!\nHere’s a quick example manifest:\napiVersion: v1 kind: PersistentVolumeClaim metadata: name: longhorn-volv-pvc spec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 2Gi --- apiVersion: v1 kind: Pod metadata: name: volume-test namespace: default spec: containers: - name: volume-test image: nginx:stable-alpine imagePullPolicy: IfNotPresent volumeMounts: - name: volv mountPath: /data ports: - containerPort: 80 volumes: - name: volv persistentVolumeClaim: claimName: longhorn-volv-pvc After saving, apply it with:\nkubectl apply -f \u0026lt;your_file\u0026gt;.yaml # Then confirm that the PV and PVC have been created: kubectl get pv kubectl get pvc ","date":"24 April 2025","externalUrl":null,"permalink":"/posts/cluster_on_a_budget/","section":"Posts","summary":"","title":"K3s Cluster on a budget","type":"posts"},{"content":"","date":"24 April 2025","externalUrl":null,"permalink":"/tags/raspberrypi/","section":"Tags","summary":"","title":"RaspberryPi","type":"tags"},{"content":" Hey, I am Erik, a Senior Cloud Architect from Bispingen, Germany.\nI am passionate about everything that has to do with tech, from Cloud and Kubernetes to Infrastructure as Code and fun side projects.\nI love exploring new ideas and sharing what I learn along the way.\nOn this blog, you will find practical tips, cool projects and plenty of tech talk.\nIf you are into DevOps, containers and automation, you are in the right place.\nBesides tech, I am also very fascinated by photography. I regularly share my work on the subject on my social media channels. Feel free to check them out.\nGlad you stopped by!\n","externalUrl":null,"permalink":"/about/","section":"About","summary":"","title":"About","type":"about"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"This website is operated privately and serves non-commercial purposes only.\nErik Haeger\nIm Bruch 14\n29646 Bispingen\nGermany\nContact: erikhaeger(at)icloud(dot)com\nThis site does not pursue any commercial intent and does not contain advertising, affiliate links, or sponsored content.\nNo liability is assumed for the content of external links. The operators of linked pages are solely responsible for their content.\n","externalUrl":null,"permalink":"/legal/","section":"Legal Notice","summary":"","title":"Legal Notice","type":"legal"},{"content":" 1. Introduction # This Privacy Policy explains how the owner of this website (hereinafter referred to as “the website”) processes personal data. This website is operated by Erik Haeger.\n2. What Data Is Collected # The Website collects the following data:\nIP Address: Your IP address is automatically recorded each time you visit the Website Request Data: Data you submit when using the Website, such as through forms or search boxes 3. How Your Data Is Used # Your data is used for the following purposes:\nTo ensure the proper functioning of the Website. 4. Data Retention # We retain your data as long as necessary to fulfill the purposes outlined in this policy.\n5. Data Sharing # Your Data may be shared with:\nCloudflare: As a CDN and security provider, Cloudflare processes traffic data.\n6. Your Rights # You have the following rights according to Art. 15 ff. DSGVO regarding your data:\nRight of access Right to rectification Right to erasure (“right to be forgotten”) Right to restriction of processing Right to data portability 7. Contact Information # If you have any questions or concerns about our privacy policy, please contact us at: erikhaeger(at)icloud(dot)com\n","externalUrl":null,"permalink":"/privacy_policy/","section":"Privacy Policy","summary":"","title":"Privacy Policy","type":"privacy_policy"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"}]