↓Skip to main content

Homelab Part 3 - Kubernetes

·8 mins
Proxmox HCI Homelab Proxmox Linux Hypervisor Virtualization Containers Kubernetes K3s
Chrisando Wagner
Author
Chrisando Wagner
25+ years across the full estate — Microsoft/VMware, OpenStack/Red Hat, cloud-native Kubernetes. These days that’s reference architectures, FinOps governance, and regulatory guardrails other teams build against. When I’m not debating gateway API controllers, I’m automating something in my homelab.
72 - This article is part of a series.

Kubernetes
#

Kubernetes is a popular open-source container orchestration platform that automates the deployment, scaling, and management of containerised applications. It provides a robust and scalable infrastructure for running microservices and containerised applications across multiple nodes.

Kubernetes is a powerful tool that enables developers to deploy, manage, and scale containerised applications across multiple nodes. It provides a declarative way to define the desired state of the application, and automatically manages the deployment, scaling, and health of the application.

Kubernetes Architecture

Homelab Architecture
#


Architecture

Most of the componets where chosen mostly because I had to choose something and can easly swapped out for example ArgoCD with Flux.

I have chosen K3S as my Kubernetes distribution of choice as it is a lightweight Kubernetes distribution that is easy to install and manage. It is designed to be lightweight and easy to use, making it ideal for small and medium-sized Kubernetes clusters.

A good alternative is K0s , a lightweight Kubernetes distribution that is easy to install and manage. It is designed to be lightweight and easy to use, making it ideal for small and medium-sized Kubernetes clusters.

For enterprise you have Redhat Openshift very opinionated and SUSE Rancher RKE2 .

Cloud providers like Google, AWS and Azure to name a few.

Homelab Kubernetes Architecture
#


homelabk3s

At this stage we have our baremetal hardware installed and ready. We will now deploy our virtual servers for our kubernetes cluster.

We will create 4 virtual servers using our Debian Template in Proxmox.

vm-clone

Install packages
#

Connect to each vm using ssh and install the following packages.

sudo apt update;
sudo apt install qemu-guest-agent -y
#start the service now and on reboot
sudo systemctl enable qemu-guest-agent --now

Configure Kernel modules & Parameters
#

sudo su;
echo "net.ipv4.conf.all.arp_ignore = 1" >> /etc/sysctl.conf;
echo "net.ipv4.conf.all.arp_announce = 1" >> /etc/sysctl.conf;
echo "net.ipv4.ip_nonlocal_bind=1" >> /etc/sysctl.conf;
echo "fs.inotify.max_user_watches=2099999999" >> /etc/sysctl.conf;
echo "fs.inotify.max_user_instances=2099999999" >> /etc/sysctl.conf;
echo "fs.inotify.max_queued_events=2099999999" >> /etc/sysctl.conf;
sysctl -p;

Install k3sup tool on desktop
#

curl -sLS https://get.k3sup.dev | sh
sudo install k3sup /usr/local/bin/

Configure Cluster
#

export TOKEN=$(head -c 64 /dev/urandom|shasum| cut -d - -f 1)
export vip=172.16.10.1
export context=cluster0

Install 1st node
#

k3sup install --user cw --ip $vip \
--token=${TOKEN} \
--k3s-extra-args '--flannel-backend=none --disable traefik --disable servicelb --disable-network-policy --disable-cloud-controller --kubelet-arg containerd=/run/k3s/containerd/containerd.sock --kubelet-arg cloud-provider=external --tls-san ${vip}  --kube-controller-manager-arg bind-address=0.0.0.0 --kube-proxy-arg metrics-bind-address=0.0.0.0 --kube-scheduler-arg bind-address=0.0.0.0 \
--tls-san=172.16.10.1 --cluster-cidr=10.42.0.0/16,fd00:10:240::/48 --service-cidr=10.43.0.0/16,fd00:10:96::/112' \
--local-path ~/.kube/config \
--merge \
--context ${context}

Please note the above can be repeated for each server node by changing the vip address.

Join worker nodes
#

k3sup join --user cw --ip 172.16.10.2 --server-ip 172.16.10.1;
k3sup join --user cw --ip 172.16.10.3 --server-ip 172.16.10.1;
k3sup join --user cw --ip 172.16.10.4 --server-ip 172.16.10.1;

Check status
#

➜ k get nodes
NAME            STATUS     ROLES                  AGE     VERSION
cluster0-cp1    NotReady   control-plane,master   18m     v1.33.6+k3s1
cluster0-wrk1   NotReady   <none>                 2m14s   v1.33.6+k3s1
cluster0-wrk2   NotReady   <none>                 52s     v1.33.6+k3s1
cluster0-wrk3   NotReady   <none>                 24s     v1.33.6+k3s1

Please note that cluster should state NotReady until we install a CNI

Gateway API
#

Install CRD’s
#

k apply --server-side -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.1/experimental-install.yaml

We using the experimental branch to enable tls routing

Cilium
#

Install cilium cli using app store or homebrew

brew install cilium-cli
cilium version
  cilium-cli: v0.18.8 compiled with go1.25.3 on darwin/arm64
  cilium image (default): v1.18.2
  cilium image (stable): v1.18.4
  cilium image (running): 1.18.4

Install Cilium using cli
#

Create values.yaml file

# values.yaml
k8sServiceHost: 172.16.10.1
k8sServicePort: 6443

kubeProxyReplacement: true

l2announcements:
  enabled: true

externalIPs:
  enabled: true

k8sClientRateLimit:
  qps: 50
  burst: 200

operator:
  # replicas: 1  # Uncomment this if you only have one node
  rollOutPods: true

rollOutCiliumPods: true
ingressController:
  enabled: false

gatewayAPI:
  enabled: true

hubble:
  relay:
    enabled: true
  ui:
    enabled: true

bgpControlPlane:
  enabled: true

Install using cli

cilium install --version 1.18.4 --values values.yaml

Check status

➜ cilium status --wait
   /¯¯\
/¯¯\__/¯¯\    Cilium:             OK
\__/¯¯\__/    Operator:           OK
/¯¯\__/¯¯\    Envoy DaemonSet:    OK
\__/¯¯\__/    Hubble Relay:       OK
   \__/       ClusterMesh:        disabled

DaemonSet              cilium                   Desired: 4, Ready: 4/4, Available: 4/4
DaemonSet              cilium-envoy             Desired: 4, Ready: 4/4, Available: 4/4Deployment             cilium-operator          Desired: 1, Ready: 1/1, Available: 1/1
Deployment             hubble-relay             Desired: 1, Ready: 1/1, Available: 1/1
Deployment             hubble-ui                Desired: 1, Ready: 1/1, Available: 1/1
Containers:            cilium                   Running: 4
                      cilium-envoy             Running: 4
                      cilium-operator          Running: 1
                      clustermesh-apiserver
                      hubble-relay             Running: 1
                      hubble-ui                Running: 1
Cluster Pods:          5/5 managed by Cilium
Helm chart version:    1.18.4
Image versions         cilium             quay.io/cilium/cilium:v1.18.4@sha256:49d87af187eeeb9e9e3ec2bc
6bd372261a0b5cb2d845659463ba7cc10fe9e45f: 4                       cilium-envoy       quay.io/cilium/cilium-envoy:v1.34.10-1762597008-ff7ae7d623be0
0078865cff1b0672cc5d9bfc6d5@sha256:1deb6709afcb5523579bf1abbc3255adf9e354565a88c4a9162c8d9cb1d77ab5: 4                       cilium-operator    quay.io/cilium/operator-generic:v1.18.4@sha256:1b22b9ff28affd
f574378a70dade4ef835b00b080c2ee2418530809dd62c3012: 1                       hubble-relay       quay.io/cilium/hubble-relay:v1.18.4@sha256:6d350cb1c84b847adb
152173debef1f774126c69de21a5921a1e6a23b8779723: 1                       hubble-ui          quay.io/cilium/hubble-ui-backend:v0.13.3@sha256:db1454e45dc39
ca41fbf7cad31eec95d99e5b9949c39daaad0fa81ef29d56953: 1                       hubble-ui          quay.io/cilium/hubble-ui:v0.13.3@sha256:661d5de7050182d495c64
97ff0b007a7a1e379648e60830dd68c4d78ae21761d: 1

At this stage you might want to taint your control plan nodes to not run any workloads as k3s by default allows workloads to run on control plan nodes.

k taint node cluster0-cp1 CriticalAddonsOnly=true:NoExecute
k config set-context --current --namespace=homelab

Lets create our l2 Announcement policy

#l2.yaml
---
apiVersion: cilium.io/v2alpha1
kind: CiliumL2AnnouncementPolicy
metadata:
  name: default-l2-announcement-policy
  namespace: kube-system
spec:
  interfaces:
    - eth0
  externalIPs: true
  loadBalancerIPs: true
---

Make sure to use the correct network interface.

Now lets create our ip pool file.

# ippool.yaml
---
apiVersion: cilium.io/v2
kind: CiliumLoadBalancerIPPool
metadata:
  name: first-pool
spec:
  blocks:
  #- cidr: 172.16.10.1/24
  - start: 172.16.10.20
  - stop: 172.16.10.200

Apply these manifests.

k apply -f l2.yaml -f ippool.yaml

Before we create our gateway lets install cert-manager to manage our certs.

Cert-Manager
#

We will install cert-manager enabling gateway api support

# Add helm repo
helm repo add jetstack https://charts.jetstack.io --force-update

# Install
helm upgrade --install \
  cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true --set "extraArgs={--feature-gates=ExperimentalGatewayAPISupport=true,--dns01-recursive-nameservers=1.1.1.1:53\,9.9.9.9:53,--dns01-recursive-nameservers-only}"

Setup and configure cert-manager to your requirements.

Example of ClusterIssuer

#ClusterIssuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt
  namespace: cert-manager
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt
    solvers:
      - dns01:
          cloudflare:
            email: [email protected]
            apiTokenSecretRef:
              name: cloudflare-token-secret
              key: cloudflare-token
        selector:
          dnsZones:
            - "c0.wagner.org.za"

Now that we have cert-manager setup we can procceed.

Create gateway
#

#gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: gateway
  namespace: kube-system
  labels:
    gateway: gateway
spec:
  gatewayClassName: cilium
  addresses:
  - type: IPAddress
    value:  172.16.10.20
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      allowedRoutes:
        namespaces:
          from: All
      tls:
        mode: Terminate
        certificateRefs:
          - kind: Secret
            name: wagner-tls
            namespace: kube-system

Apply it

k apply -f gateway.yaml

Check status

k get gateway -n kube-system
    NAME          CLASS    ADDRESS       PROGRAMMED   AGE
    gateway   cilium   172.16.10.20   True         20m

Add the following to your DNS server for name resourslutions or to your hosts file.

172.16.10.20     *.apps.c0.wagner.org.za

Now lets create a route for hubble ui

#hubble-ui-httproute.yaml
apiVersion: gateway.networking.k8s.io/v1beta1
kind: HTTPRoute
metadata:
  name: hubble
  namespace: kube-system
spec:
  parentRefs:
    - name: gateway
      namespace: kube-system
      sectionName: https
  hostnames:
    - "hubble.apps.c0.wagner.org.za"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - kind: Service
          name: hubble-ui
          port: 80

Apply the route

k apply -f hubble-ui-httproute.yaml

Check routes.

k get httproutes.gateway.networking.k8s.io -n kube-system
NAME     HOSTNAMES                           AGE
hubble   ["hubble.apps.c0.wagner.org.za"]   20h

eBGP
#

Currently we are not able to route to our services as our network has now way of discovering these routes. We will now deploy BGP sharing and have cilium advertise our services to our router (Mikrotik).

Create bpg-peering manifest.

#bgp-peering-policy.yaml
apiVersion: "cilium.io/v2alpha1"
kind: CiliumBGPPeeringPolicy
metadata:
 name: bgp-peering-policy
spec:
 nodeSelector:
   matchLabels:
     bgp_enabled: "true"
 virtualRouters:
 - localASN: 64512
   exportPodCIDR: true
   neighbors:
    - peerAddress: '192.168.7.254/32'
      peerASN: 64512
   serviceSelector:
     matchExpressions:
       - {key: somekey, operator: NotIn, values: ['never-used-value']}

As you can see we only deploying to nodes labled bpg_enabled=true Lets lable our worker nodes

k label nodes wrk1 wrk2 wrk3 bgp_enabled="true"
node/wrk1 labeled
node/wrk2 labeled
node/wrk3 labeled

Now lets create our bgp policy.

k apply -f bgp-peering-policy.yaml

Now configure your route to match the bgp settings to your worker nodes in my example 192.168.50.2/32, 192.168.50.3/32 and 192.168.50.4/32.

Check status

cilium bgp peers
➜ cilium bgp peers
Node   Local AS   Peer AS   Peer Address    Session State   Uptime    Family         Received   Advertised
cp     65000      65000     192.168.7.254   established     2h1m31s   ipv4/unicast   1          9
                                                                     ipv6/unicast   0          0
wrk1   65000      65000     192.168.7.254   established     2h1m31s   ipv4/unicast   1          9
                                                                     ipv6/unicast   0          0
wrk2   65000      65000     192.168.7.254   established     2h1m32s   ipv4/unicast   1          9
                                                                     ipv6/unicast   0          0
wrk3   65000      65000     192.168.7.254   established     2h1m42s   ipv4/unicast   1          9
                                                                     ipv6/unicast   0          0

If the status is active and doesnt change to established check your router config and make sure you have bgp peering partners configured for each worker node.

Test connection

➜ curl -v https://hubble.apps.c0.wagner.org.za
* Host hubble.apps.c0.wagner.org.za:443 was resolved.
* IPv6: (none)
* IPv4: 172.16.10.20
*   Trying 172.16.10.20:443...
* Connected to hubble.apps.c0.wagner.org.za (172.16.10.20) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/cert.pem
*  CApath: none
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server did not agree on a protocol. Uses default.
* Server certificate:
*  subject: CN=*.c0.wagner.org.za
*  start date: Dec 14 09:09:07 2025 GMT
*  expire date: Mar 14 09:09:06 2026 GMT
*  subjectAltName: host "hubble.apps.c0.wagner.org.za" matched cert's "*.apps.c0.wagner.org.za"
*  issuer: C=US; O=Let's Encrypt; CN=R13
*  SSL certificate verify ok.
* using HTTP/1.x
> GET / HTTP/1.1
> Host: hubble.apps.c0.wagner.org.za
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< server: envoy
< date: Tue, 30 Dec 2025 16:24:01 GMT
< content-type: text/html
< content-length: 702
< last-modified: Wed, 03 Sep 2025 09:25:19 GMT
< etag: "68b8097f-2be"
< accept-ranges: bytes
< x-envoy-upstream-service-time: 2
<
* Connection #0 to host hubble.apps.c0.wagner.org.za left intact
<!doctype html><html><head><meta charset="utf-8"/><title>Hubble UI</title><base href="/"/><meta name="color-scheme" content="only light"/><meta http-equiv="X-UA-Compatible" content="IE=edge"/><meta name="viewport" content="width=device-width,user-scalable=0,initial-scale=1,minimum-scale=1,maximum-scale=1"/><link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png"/><link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png"/><link rel="shortcut icon" href="favicon.ico"/><script defer="defer" src="bundle.main.046b68211246343ab6d5.js"></script><link href="bundle.main.250ac4c5d4e736844d1e.css" rel="stylesheet"></head><body><div id="app" class="test"></div></body></html>%

Using cilium cli.

cilium connectivity test

Check out Kairos — Immutable Kubernetes OS

72 - This article is part of a series.