< back to notes

Trying out Kubernetes with K3S, Calico, Rancher and Longhorn

Overview

I wanted to try out Kubernetes for the first time, before this I have had no experience with it.

This is going to be quite long, in addition to that, the first half of this was written with all the nodes running on potato hardware. The nodes were migrated to a much better host half way through writing this.

By no means is this a guide, just some notes.

Provisioning Resources

I provisioned 3 Ubuntu Server machines with the following specs:

  • Boot disk: 64gb
  • Storage disk at /storage: 128gb
  • 32gb memory (memory ballooning off, KSM off)
  • 12 cores (pinned to dedicated cores w/ NUMA on)

1. Installation of K3S

Installed K3S on the first node:

curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--cluster-init --flannel-backend=none --disable-network-policy" sh -

Grabbed the node token with sudo cat /var/lib/rancher/k3s/server/node-token

On nodes 2 and 3:

curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--flannel-backend=none --disable-network-policy --server https://10.1.102.4:6443" K3S_TOKEN="K10...c3" sh -

Checked status:

kubectl get nodes
NAME                 STATUS   ROLES                AGE   VERSION
ny-pbg-k3s-node-01   Ready    control-plane,etcd   44h   v1.36.4+k3s1
ny-pbg-k3s-node-02   Ready    control-plane,etcd   44h   v1.36.4+k3s1
ny-pbg-k3s-node-03   Ready    control-plane,etcd   44h   v1.36.4+k3s1

2.1. Calico: Setup

Installed Helm on node 1:

curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash

Added Calico repository:

helm repo add projectcalico https://docs.tigera.io/calico/charts
helm repo update

Pointed Helm to the K3s config:

export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
echo 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml' >> ~/.bashrc
source ~/.bashrc

Installed Tiegera Operator:

helm install calico projectcalico/tigera-operator \
  --version v3.31.0 \
  --namespace tigera-operator \
  --create-namespace

NAME: calico
LAST DEPLOYED: Fri Aug 28 23:49:28 2026
NAMESPACE: tigera-operator
STATUS: deployed
REVISION: 1
TEST SUITE: None

I used v3.31.0 for ease of installation because of issue #12860

Ensured pod in the tigera-operator namespace was running:

kubectl get pods -n tigera-operator
NAME                               READY   STATUS    RESTARTS   AGE
tigera-operator-76b59488db-89szb   1/1     Running   0          89s

Created calico/installation.yaml with contents:

apiVersion: operator.tigera.io/v1
kind: Installation
metadata:
  name: default
spec:
  calicoNetwork:
    ipPools:
    - name: home-pool
      cidr: 10.10.0.0/20
      encapsulation: None # disable all encapsulation types
      natOutgoing: Enabled
      nodeSelector: all()
      blockSize: 26
    bgp: Enabled

then

kubectl apply -f calico/installation.yaml

Watched the pods create live with kubectl get pods -n calico-system -w

Waited ~10 minutes and confirmed pods are all Running:

sudo kubectl get pods -n calico-system
NAME                                       READY   STATUS    RESTARTS        AGE
calico-apiserver-6c4d97bf86-5hk42          1/1     Running   3 (116s ago)    9m8s
calico-apiserver-6c4d97bf86-gkz82          1/1     Running   0               9m8s
calico-kube-controllers-58cf6cd75b-7xs4v   1/1     Running   0               9m5s
calico-node-47pc5                          1/1     Running   0               9m6s
calico-node-8dbkp                          1/1     Running   1 (6m18s ago)   9m6s
calico-node-kqrtr                          1/1     Running   0               9m6s
calico-typha-6694f545b9-7m2rl              1/1     Running   0               9m6s
calico-typha-6694f545b9-gt9z4              1/1     Running   0               8m56s
goldmane-95bd7cc99-z6fb4                   1/1     Running   0               9m8s
whisker-f54f84859-5rzbx                    2/2     Running   0               3m7s

2.2. Calico: FortiGate eBGP peering

Created a config file for the BGP configuration:

apiVersion: projectcalico.org/v3
kind: BGPConfiguration
metadata:
  name: default
spec:
  logSeverityScreen: Info
  nodeToNodeMeshEnabled: false
  asNumber: 65401

Applied config:

kubectl apply -f calico/bgpconfig.yaml
bgpconfiguration.projectcalico.org/default created

Created bgppeer.yaml config:

apiVersion: projectcalico.org/v3
kind: BGPPeer
metadata:
  name: fortigate-peer
spec:
  peerIP: 10.1.102.1
  asNumber: 65400

Applied config:

kubectl apply -f bgppeer.yaml 
bgppeer.projectcalico.org/fortigate-peer created

Confirmed configs are present:

kubectl get bgpconfiguration
NAME      CREATED AT
default   2026-08-29T00:38:50Z

kubectl get bgppeer
NAME             CREATED AT
fortigate-peer   2026-08-29T01:22:54Z

Install Calicoctl tool:

curl -L https://github.com/projectcalico/calico/releases/download/v3.31.0/calicoctl-linux-amd64 -o calicoctl
chmod +x calicoctl
sudo mv calicoctl /usr/local/bin/

Add BGP neighbors in FortiGate:

config router bgp
   config neighbor
      edit "10.12.0.2"
          set remote-as 65400
          set route-reflector-client enable
      next
      edit "10.1.102.4"
          set name "k3s-node-01"
          set remote-as 65401
      next
      edit "10.1.102.5"
          set name "k3s-node-02"
          set remote-as 65401
      next
      edit "10.1.102.6"
          set name "k3s-node-03"
          set remote-as 65401
      next
   end
end

Check Calico status:

calicoctl node status

Calico process is running.

IPv4 BGP status
+--------------+-----------+-------+----------+-------------+
| PEER ADDRESS | PEER TYPE | STATE |  SINCE   |    INFO     |
+--------------+-----------+-------+----------+-------------+
| 10.1.102.1   | global    | up    | 01:30:57 | Established |
+--------------+-----------+-------+----------+-------------+

IPv6 BGP status
No IPv6 peers found.

Confirm FortiGate has installed routes learned from Calico:

get router info routing-table all

...
B       10.10.0.192/26 [20/0] via 10.1.102.5 (recursive is directly connected, vlan102), 00:02:35, [1/0]
B       10.10.7.192/26 [20/0] via 10.1.102.6 (recursive is directly connected, vlan102), 00:02:34, [1/0]
B       10.10.10.0/26 [20/0] via 10.1.102.4 (recursive is directly connected, vlan102), 00:02:36, [1/0]
...

2.3. Calico: Service Address Configuration

Created a service pool config:

apiVersion: projectcalico.org/v3
kind: IPPool
metadata:
  name: service-pool
spec:
  cidr: 10.10.99.0/28
  blockSize: 28
  allowedUses:
  - LoadBalancer

Apply it:

kubectl apply -f calico/servicepool.yaml
ippool.projectcalico.org/service-pool created

Modify the original bgp config to include serviceLoadBalancerIPs:

apiVersion: projectcalico.org/v3
kind: BGPConfiguration
metadata:
  name: default
spec:
  logSeverityScreen: Info
  nodeToNodeMeshEnabled: false
  asNumber: 65401
  serviceLoadBalancerIPs:
    - cidr: 10.10.99.0/28

Apply it:

kubectl apply -f calico/bgpconfig.yaml
bgpconfiguration.projectcalico.org/default configured

Verify configs took:

kubectl get ippool service-pool -o yaml
apiVersion: projectcalico.org/v3
kind: IPPool
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"projectcalico.org/v3","kind":"IPPool","metadata":{"annotations":{},"name":"service-pool"},"spec":{"allowedUses":["LoadBalancer"],"blockSize":28,"cidr":"10.10.99.0/28"}}
  creationTimestamp: "2026-08-29T16:51:52Z"
  name: service-pool
  resourceVersion: "247656"
  uid: 39d21198-8064-4eaf-a717-308b08f7f001
spec:
  allowedUses:
  - LoadBalancer
  assignmentMode: Automatic
  blockSize: 28
  cidr: 10.10.99.0/28
  ipipMode: Never
  nodeSelector: all()
  vxlanMode: Never
kubectl get bgpconfiguration default -o yaml
apiVersion: projectcalico.org/v3
kind: BGPConfiguration
metadata:
  annotations:
    kubectl.kubernetes.io/last-applied-configuration: |
      {"apiVersion":"projectcalico.org/v3","kind":"BGPConfiguration","metadata":{"annotations":{},"name":"default"},"spec":{"asNumber":65401,"logSeverityScreen":"Info","nodeToNodeMeshEnabled":false,"serviceLoadBalancerIPs":[{"cidr":"10.10.99.0/28"}]}}
  creationTimestamp: "2026-08-29T00:38:50Z"
  name: default
  resourceVersion: "247690"
  uid: 19442b05-213d-4d4d-ba72-2adf60242ef9
spec:
  asNumber: 65401
  logSeverityScreen: Info
  nodeToNodeMeshEnabled: false
  serviceLoadBalancerAggregation: Enabled
  serviceLoadBalancerIPs:
  - cidr: 10.10.99.0/28

2.4. Calico: Testing Service IPs

I will deploy a test app against the cluster to ensure an IP address is assigned in the service pool, advertised up to the FortiGate and is reachable.

I will be using Nginx to test.

Create the deployment for the test:

kubectl create deployment nginx-test --image=nginx
deployment.apps/nginx-test created

Expose the app:

kubectl expose deployment nginx-test --port=80 --type=LoadBalancer
service/nginx-test exposed

Confirm app has an EXTERNAL-IP entry:

kubectl get svc nginx-test
NAME         TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
nginx-test   LoadBalancer   10.43.211.112   10.10.99.1    80:31556/TCP   8m57s

Confirm range is advertised back to the FortiGate:

get router info routing-table all
...
B       10.10.0.192/26 [20/0] via 10.1.102.5 (recursive is directly connected, vlan102), 15:25:46, [1/0]
B       10.10.7.192/26 [20/0] via 10.1.102.6 (recursive is directly connected, vlan102), 15:25:45, [1/0]
B       10.10.10.0/26 [20/0] via 10.1.102.4 (recursive is directly connected, vlan102), 15:25:47, [1/0]
B       10.10.99.0/28 [20/0] via 10.1.102.6 (recursive is directly connected, vlan102), 00:04:48, [1/0]
...

Confirm app is accessible on my local machine:

curl 10.10.99.1
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

Confirm Traefik is accessible too:

kubectl get svc -n kube-system
NAME             TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)                      AGE
kube-dns         ClusterIP      10.43.0.10     <none>        53/UDP,53/TCP,9153/TCP       16h
metrics-server   ClusterIP      10.43.120.44   <none>        443/TCP                      16h
traefik          LoadBalancer   10.43.57.44    10.10.99.0    80:30831/TCP,443:32636/TCP   16h
curl 10.10.99.0
404 page not found

3. DNS Setup and Traefik testing

I run a Technitium DNS cluster, so I will be using that to respond for DNS for this lab

I created a primary zone called k3s-lab.homeblab.com, and a wildcard A record to point to Traefik *.k3s-lab.homeblab.com -> 10.10.99.0

My thought was that apps can be individually exposed if they do not need to be proxied, but most apps will go through the proxy, in which more specific DNS records can be created when that happens.

Testing the DNS changes to ensure that Traefik is responding:

404 page not found

Next I’ll create an IngressRoute for Traefik to route a request to the Nginx test based off the requested host header

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: nginx-test
  namespace: default
spec:
  entryPoints:
    - web
  routes:
    - match: Host(`nginx-test.k3s-lab.homeblab.com`)
      kind: Rule
      services:
        - name: nginx-test
          port: 80

Apply it:

kubectl apply -f nginx-test/nginx-ingressroute.yaml 
ingressroute.traefik.io/nginx-test created

Testing:

curl nginx-test.k3s-lab.homeblab.com
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
...

4. Persistant Storage Setup

Most apps I’ll be running in the lab will be stateful, which will require persistant storage.

Since this is running K3S, Rancher already has a program called Longhorn, which I will be using here.

I read online that it might not be the most performant system for distrubuted stateful storage, but if that becomes an issue, I might switch to a centeralized storage system instead.

Install pre-reqs on each node:

sudo apt update
sudo apt install -y open-iscsi nfs-common
sudo systemctl enable --now iscsid

Prep:

lsmod | grep iscsi_tcp

sudo modprobe iscsi_tcp

echo "iscsi_tcp" | sudo tee -a /etc/modules-load.d/modules.conf

lsmod | grep iscsi_tcp
iscsi_tcp              24576  0
libiscsi_tcp           32768  1 iscsi_tcp
libiscsi               77824  2 libiscsi_tcp,iscsi_tcp
scsi_transport_iscsi   172032  4 libiscsi_tcp,iscsi_tcp,libiscsi

sudo systemctl status iscsid
 iscsid.service - iSCSI initiator daemon (iscsid)
     Loaded: loaded (/usr/lib/systemd/system/iscsid.service; enabled; preset: enabled)
     Active: active (running) since Sat 2026-08-29 17:29:55 UTC; 7min ago
TriggeredBy: iscsid.socket
       Docs: man:iscsid(8)
    Process: 166877 ExecStartPre=/usr/lib/open-iscsi/startup-checks.sh (code=exited, status=0/SUCCESS)
    Process: 166881 ExecStart=/usr/sbin/iscsid (code=exited, status=0/SUCCESS)
   Main PID: 166883 (iscsid)
      Tasks: 2 (limit: 18956)
     Memory: 2.7M (peak: 2.9M)
        CPU: 224ms
     CGroup: /system.slice/iscsid.service
             ├─166882 /usr/sbin/iscsid
             └─166883 /usr/sbin/iscsid

Aug 29 17:29:55 ny-pbg-k3s-node-01 systemd[1]: Starting iscsid.service - iSCSI initiator daemon (iscsid)...
Aug 29 17:29:55 ny-pbg-k3s-node-01 iscsid[166881]: iSCSI logger with pid=166882 started!
Aug 29 17:29:55 ny-pbg-k3s-node-01 systemd[1]: Started iscsid.service - iSCSI initiator daemon (iscsid).
Aug 29 17:29:56 ny-pbg-k3s-node-01 iscsid[166882]: iSCSI daemon with pid=166883 started!

sudo systemctl status multipathd
 multipathd.service - Device-Mapper Multipath Device Controller
     Loaded: loaded (/usr/lib/systemd/system/multipathd.service; enabled; preset: enabled)
     Active: active (running) since Sat 2026-08-29 01:05:42 UTC; 16h ago
TriggeredBy: multipathd.socket
   Main PID: 445 (multipathd)
     Status: "up"
      Tasks: 7
     Memory: 21.9M (peak: 22.5M)
        CPU: 18.533s
     CGroup: /system.slice/multipathd.service
             └─445 /sbin/multipathd -d -s

Aug 29 01:05:41 ny-pbg-k3s-node-01 systemd[1]: Starting multipathd.service - Device-Mapper Multipath Device Controller>
Aug 29 01:05:50 ny-pbg-k3s-node-01 multipathd[445]: multipathd v0.9.4: start up
Aug 29 01:05:50 ny-pbg-k3s-node-01 multipathd[445]: reconfigure: setting up paths and maps
Aug 29 01:05:42 ny-pbg-k3s-node-01 systemd[1]: Started multipathd.service - Device-Mapper Multipath Device Controller.

sudo systemctl stop multipathd
sudo systemctl disable multipathd
sudo systemctl stop multipathd.socket
sudo systemctl disable multipathd.socket

Install Longhorn via Helm:

helm repo add longhorn https://charts.longhorn.io
helm repo update
helm install longhorn longhorn/longhorn \
  --namespace longhorn-system \
  --create-namespace \
  --set defaultSettings.defaultDataPath="/storage"

NAME: longhorn
LAST DEPLOYED: Sat Aug 29 17:39:58 2026
NAMESPACE: longhorn-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
Longhorn is now installed on the cluster!

Please wait a few minutes for other Longhorn components such as CSI deployments, Engine Images, and Instance Managers to be initialized.

Visit our documentation at https://longhorn.io/docs/

The /storage is the mount point for a dedicated 128gb disk on each node.

Wait a few minutes and confirm all are running:

kubectl get pods -n longhorn-system
NAME                                                READY   STATUS    RESTARTS   AGE
csi-attacher-866df4b764-75w56                       1/1     Running   0          5m51s
csi-attacher-866df4b764-f4g9p                       1/1     Running   0          5m51s
csi-attacher-866df4b764-hcp6m                       1/1     Running   0          5m51s
csi-provisioner-5c696f97cd-4rd4v                    1/1     Running   0          5m51s
csi-provisioner-5c696f97cd-hgv59                    1/1     Running   0          5m51s
csi-provisioner-5c696f97cd-j8cwx                    1/1     Running   0          5m51s
csi-resizer-7dd456f456-6rrpx                        1/1     Running   0          5m51s
csi-resizer-7dd456f456-7fxrq                        1/1     Running   0          5m51s
csi-resizer-7dd456f456-rq648                        1/1     Running   0          5m51s
csi-snapshotter-7997dc5fcd-tqrks                    1/1     Running   0          5m51s
csi-snapshotter-7997dc5fcd-wn5m8                    1/1     Running   0          5m51s
csi-snapshotter-7997dc5fcd-xzhrb                    1/1     Running   0          5m51s
engine-image-ei-493e04e7-8kf5z                      1/1     Running   0          32m
engine-image-ei-493e04e7-cbr9p                      1/1     Running   0          32m
engine-image-ei-493e04e7-sgtdn                      1/1     Running   0          32m
instance-manager-a2261654d56025aa4ea4a59cbb4118ac   1/1     Running   0          32m
instance-manager-b113249abab2064b5eab2a310d3ad238   1/1     Running   0          32m
instance-manager-feb7351f40381660ac49cc352969b1fc   1/1     Running   0          32m
longhorn-csi-plugin-258ms                           3/3     Running   0          5m51s
longhorn-csi-plugin-88gr7                           3/3     Running   0          5m51s
longhorn-csi-plugin-t8xqm                           3/3     Running   0          5m51s
longhorn-driver-deployer-8688565869-9prtl           1/1     Running   0          6m2s
longhorn-manager-kn6xd                              2/2     Running   0          87s
longhorn-manager-nh2kj                              2/2     Running   0          87s
longhorn-manager-q7t7d                              2/2     Running   0          87s
longhorn-ui-79b9b8cbdc-4x2bz                        1/1     Running   0          33m
longhorn-ui-79b9b8cbdc-xr5cc                        1/1     Running   0          33m

Check if Longhorn is healthy:

kubectl get nodes.longhorn.io -n longhorn-system -o wide
NAME                 READY   ALLOWSCHEDULING   SCHEDULABLE   AGE
ny-pbg-k3s-node-01   True    true              True          12m
ny-pbg-k3s-node-02   True    true              True          11m
ny-pbg-k3s-node-03   True    true              True          17m

Get current storageclass:

kubectl get storageclass
NAME                   PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  17h
longhorn (default)     driver.longhorn.io      Delete          Immediate              true                   18m
longhorn-static        driver.longhorn.io      Delete          Immediate              true                   17m

Set Longhorn as the only default:

kubectl patch storageclass local-path -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"false"}}}'
storageclass.storage.k8s.io/local-path patched

ubectl get storageclass
NAME                 PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path           rancher.io/local-path   Delete          WaitForFirstConsumer   false                  17h
longhorn (default)   driver.longhorn.io      Delete          Immediate              true                   19m
longhorn-static      driver.longhorn.io      Delete          Immediate              true                   19m

Add IngressRoute for Longhorn Storage UI:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: longhorn-ipallowlist
  namespace: longhorn-system
spec:
  ipAllowList:
    sourceRange:
      - 10.0.0.0/8 # home network range
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: longhorn-ui
  namespace: longhorn-system
spec:
  entryPoints:
    - web
  routes:
    - match: Host(`longhorn.k3s-lab.homeblab.com`)
      kind: Rule
      services:
        - name: longhorn-frontend
          port: 80
          sticky:
            cookie:
              name: longhorn-lb
              httpOnly: true

      middlewares:
        - name: longhorn-ipallowlist

Apply it:

kubectl apply -f longhorn/longhorn-ingressroute.yaml
kubectl apply -f longhorn/longhorn-ingressroute.yaml
middleware.traefik.io/longhorn-ipallowlist created
ingressroute.traefik.io/longhorn-ui created

The IngressRoute is scoped down to source IP addresses within 10.0.0.0/8 for now.

Test persistant storage:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: test-pvc
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
---
apiVersion: v1
kind: Pod
metadata:
  name: test-pvc-pod
spec:
  containers:
  - name: test
    image: busybox
    command: ["sh", "-c", "echo hello from longhorn > /data/test.txt && sleep 3600"]
    volumeMounts:
    - name: data
      mountPath: /data
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: test-pvc

Apply:

kubectl apply -f longhorn/longhorn-test.yml 
persistentvolumeclaim/test-pvc created
pod/test-pvc-pod created
kubectl get pvc test-pvc
NAME       STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
test-pvc   Bound    pvc-3f55df69-ec79-4b1b-a469-2895a0307892   1Gi        RWO            longhorn       <unset>                 12s
kubectl get pod test-pvc-pod -w
NAME           READY   STATUS              RESTARTS   AGE
test-pvc-pod   0/1     ContainerCreating   0          12s
test-pvc-pod   0/1     ContainerCreating   0          36s
test-pvc-pod   0/1     ContainerCreating   0          36s
test-pvc-pod   1/1     Running             0          37s
kubectl exec test-pvc-pod -- cat /data/test.txt
hello from longhorn

Check pvc health status:

kubectl get volumes.longhorn.io -n longhorn-system
NAME                                       DATA ENGINE   STATE      ROBUSTNESS   SCHEDULED   SIZE         NODE                 AGE
pvc-3f55df69-ec79-4b1b-a469-2895a0307892   v1            attached   healthy                  1073741824   ny-pbg-k3s-node-03   11m

Tear down and confirm clean up:

kubectl delete pod test-pvc-pod
pod "test-pvc-pod" deleted from default namespace

kubectl delete pvc test-pvc
persistentvolumeclaim "test-pvc" deleted from default namespace

kubectl get pod test-pvc 
Error from server (NotFound): pods "test-pvc" not found

kubectl get pvc test-pvc
Error from server (NotFound): persistentvolumeclaims "test-pvc" not found

kubectl get volumes.longhorn.io -n longhorn-system
No resources found in longhorn-system namespace.

NOTE: I had some very strange issues occuring with Longhorn UI and the Driver deployment pod crashing due to some restrictive policies put in place during the installation of Longhorn… To get to this point, I had to delete the network policies Longhorn put in place with kubectl delete networkpolicy --all -n longhorn-system and remove the disks and re-add them to Longhorn. The web UI was quite buggy where the websocket connections would fail 99% of the time, in doing this, it has resolved itself, and has resolved the driver deployment pod from crashing.

5. Rancher installation

Install Cert-Manager via Helm:

helm repo add jetstack https://charts.jetstack.io
"jetstack" has been added to your repositories

helm repo update
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "longhorn" chart repository
...Successfully got an update from the "jetstack" chart repository
...Successfully got an update from the "projectcalico" chart repository
Update Complete. ⎈Happy Helming!⎈

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true
NAME: cert-manager
LAST DEPLOYED: Sat Aug 29 21:32:38 2026
NAMESPACE: cert-manager
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
cert-manager v1.21.1 has been deployed successfully!

In order to begin issuing certificates, you will need to set up a ClusterIssuer
or Issuer resource (for example, by creating a 'letsencrypt-staging' issuer).

More information on the different types of issuers and how to configure them
can be found in our documentation:

https://cert-manager.io/docs/configuration/

For information on how to configure cert-manager to automatically provision
Certificates for Ingress resources, take a look at the `ingress-shim`
documentation:

https://cert-manager.io/docs/usage/ingress/

For information on how to configure cert-manager to automatically provision
Certificates for Gateway API resources, take a look at the `gateway resource`
documentation:

https://cert-manager.io/docs/usage/gateway/

Confirm pods are running:

kubectl get pods -n cert-manager
NAME                                       READY   STATUS    RESTARTS   AGE
cert-manager-689c4c5575-jdnxh              1/1     Running   0          87s
cert-manager-cainjector-6fbb9c8cd6-6glvx   1/1     Running   0          87s
cert-manager-webhook-646c95c5ff-h47m5      1/1     Running   0          87s

Add Rancher repo to Helm:

helm repo add rancher-stable https://releases.rancher.com/server-charts/stable
"rancher-stable" has been added to your repositories

helm repo update
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "longhorn" chart repository
...Successfully got an update from the "rancher-stable" chart repository
...Successfully got an update from the "projectcalico" chart repository
...Successfully got an update from the "jetstack" chart repository
Update Complete. ⎈Happy Helming!⎈

Install Rancher via Helm:

helm install rancher rancher-stable/rancher \
  --namespace cattle-system \
  --create-namespace \
  --set hostname=rancher.k3s-lab.homeblab.com \
  --set bootstrapPassword=ChangeMe123! \
  --set ingress.enabled=false

NAME: rancher
LAST DEPLOYED: Sat Aug 29 19:07:10 2026
NAMESPACE: cattle-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
Rancher Server has been installed. Rancher may take several minutes to fully initialize.

Please standby while Certificates are being issued, Containers are started and the Ingress rule comes up.

Check out our docs at https://rancher.com/docs/

## First Time Login

If you provided your own bootstrap password during installation, browse to https://rancher.k3s-lab.homeblab.com to get started.
If this is the first time you installed Rancher, get started by running this command and clicking the URL it generates:

\`\`\`
echo https://rancher.k3s-lab.homeblab.com/dashboard/?setup=$(kubectl get secret --namespace cattle-system bootstrap-secret -o go-template='{{.data.bootstrapPassword|base64decode}}')
\`\`\`

To get just the bootstrap password on its own, run:

\`\`\`
kubectl get secret --namespace cattle-system bootstrap-secret -o go-template='{{.data.bootstrapPassword|base64decode}}{{ "\n" }}'
\`\`\`

ingress.enabled is set to false because we will create an IngressRoute via Traefik to access rancher.k3s-lab.homeblab.com

Let this run and create an IngressRoute for access to the Rancher UI:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: rancher-ipallowlist
  namespace: cattle-system
spec:
  ipAllowList:
    sourceRange:
      - 10.1.102.0/24
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: rancher-ui
  namespace: cattle-system
spec:
  entryPoints:
    - web
    - websecure
  routes:
    - match: Host(`rancher.k3s-lab.homeblab.com`)
      kind: Rule
      services:
        - name: rancher
          port: 80
      middlewares:
        - name: rancher-ipallowlist

Note the websecure entrypoint, Rancher will auto redirect to HTTPS.

Ensured that all the pods are up and running before interacting with the service:

kubectl get pods -n cattle-system -w
NAME                               READY   STATUS      RESTARTS       AGE
helm-operation-4qvf5               0/2     Completed   0              3m50s
helm-operation-fpm49               0/2     Completed   0              3m30s
helm-operation-grq95               0/2     Completed   0              4m5s
helm-operation-lbxmq               0/2     Completed   0              2m45s
helm-operation-ms5gq               0/2     Completed   0              2m59s
helm-operation-qm25b               0/2     Completed   0              3m15s
helm-operation-v7zq2               0/2     Completed   0              4m24s
helm-operation-vmj67               0/2     Completed   0              2m22s
rancher-6f9b9d6595-7jkqz           1/1     Running     0              6m18s
rancher-6f9b9d6595-gtc65           1/1     Running     1 (5m9s ago)   6m18s
rancher-6f9b9d6595-sswrv           1/1     Running     0              6m18s
rancher-webhook-84c5997878-hbpdx   1/1     Running     0              2m56s

After accepting the EULA and going past the bootstrapping options, I was able to get into the Rancher UI and view the cluster.

Rancher UI

Rancher UI

Rancher UI

6. First real app deployment

I want to deploy authentik for a IDP for apps within the lab, reading the documentation, it does have a Helm Chart, but bundles in a database useful for testing/demo purposes, but not for production, as I want the lab to stand long term, I’ll be using Cloud Native Postgres deployment to create a DB cluster for Authentik.

I’ll install CNPG:

helm repo add cnpg https://cloudnative-pg.github.io/charts
"cnpg" has been added to your repositories

helm repo update
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "longhorn" chart repository
...Successfully got an update from the "cnpg" chart repository
...Successfully got an update from the "rancher-stable" chart repository
...Successfully got an update from the "jetstack" chart repository
...Successfully got an update from the "projectcalico" chart repository
Update Complete. ⎈Happy Helming!⎈

helm install cnpg cnpg/cloudnative-pg -n cnpg-system --create-namespace
AME: cnpg
LAST DEPLOYED: Sun Aug 30 16:52:23 2026
NAMESPACE: cnpg-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
CloudNativePG operator should be installed in namespace "cnpg-system".
You can now create a PostgreSQL cluster with 3 nodes as follows:

cat <<EOF | kubectl apply -f -
# Example of PostgreSQL cluster
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: cluster-example
  
spec:
  instances: 3
  storage:
    size: 1Gi
EOF

kubectl get -A cluster

kubectl get pods -n cnpg-system
NAME                                   READY   STATUS    RESTARTS   AGE
cnpg-cloudnative-pg-7d855d69bb-f2rn4   1/1     Running   0          83s

Now to provision the Database Cluster for Authentik:

apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: authentik-db
  namespace: authentik
spec:
  instances: 3
  storage:
    size: 10Gi
    storageClass: longhorn
  bootstrap:
    initdb:
      database: authentik
      owner: authentik
      secret:
        name: authentik-db-credentials
---
apiVersion: v1
kind: Secret
metadata:
  name: authentik-db-credentials
  namespace: authentik
type: kubernetes.io/basic-auth
stringData:
  username: authentik
  password: "NOT_FOR_YOU_TO_SEE"

Notice the storageClass option is set to longhorn, the storage system setup earlier for stateful storage across the K3s Cluster.

Apply config and watch DB Cluster create:

kubectl create namespace authentik
namespace/authentik created

kubectl apply -f authentik/authentik-db.yaml
cluster.postgresql.cnpg.io/authentik-db created
secret/authentik-db-credentials created

kubectl get pods -n authentik -w
NAME                          READY   STATUS     RESTARTS   AGE
authentik-db-1-initdb-lqvld   0/1     Init:0/1   0          18s
authentik-db-1-initdb-lqvld   0/1     Init:0/1   0          23s
authentik-db-1-initdb-lqvld   0/1     Init:0/1   0          24s
authentik-db-1-initdb-lqvld   0/1     Init:0/1   0          24s
authentik-db-1-initdb-lqvld   0/1     PodInitializing   0          24s
authentik-db-1-initdb-lqvld   1/1     Running           0          42s
authentik-db-1-initdb-lqvld   0/1     Completed         0          59s
authentik-db-1-initdb-lqvld   0/1     Completed         0          61s
authentik-db-1-initdb-lqvld   0/1     Completed         0          61s
authentik-db-1-initdb-lqvld   0/1     Completed         0          61s
authentik-db-1                0/1     Pending           0          0s
authentik-db-1                0/1     Pending           0          0s
authentik-db-1                0/1     Init:0/1          0          0s
authentik-db-1                0/1     Init:0/1          0          10s
authentik-db-1                0/1     Init:0/1          0          10s
authentik-db-1                0/1     Init:0/1          0          10s
authentik-db-1                0/1     PodInitializing   0          12s
authentik-db-1                0/1     Running           0          12s
authentik-db-1                0/1     Running           0          14s
authentik-db-1                0/1     Running           0          20s
authentik-db-1                1/1     Running           0          21s
authentik-db-2-join-97klg     0/1     Pending           0          0s
authentik-db-2-join-97klg     0/1     Pending           0          0s
authentik-db-2-join-97klg     0/1     Pending           0          2s
authentik-db-2-join-97klg     0/1     Init:0/1          0          2s
authentik-db-2-join-97klg     0/1     Init:0/1          0          24s
authentik-db-2-join-97klg     0/1     Init:0/1          0          24s
authentik-db-2-join-97klg     0/1     Init:0/1          0          28s
authentik-db-2-join-97klg     0/1     PodInitializing   0          28s
authentik-db-2-join-97klg     1/1     Running           0          46s
authentik-db-2-join-97klg     0/1     Completed         0          73s
authentik-db-2-join-97klg     0/1     Completed         0          74s
authentik-db-2-join-97klg     0/1     Completed         0          75s
authentik-db-2-join-97klg     0/1     Completed         0          75s
authentik-db-2                0/1     Pending           0          0s
authentik-db-2                0/1     Pending           0          0s
authentik-db-2                0/1     Init:0/1          0          0s
authentik-db-2                0/1     Init:0/1          0          5s
authentik-db-2                0/1     Init:0/1          0          5s
authentik-db-2                0/1     Init:0/1          0          5s
authentik-db-2                0/1     PodInitializing   0          6s
authentik-db-2                0/1     Running           0          6s
authentik-db-2                0/1     Running           0          11s
authentik-db-2                0/1     Running           0          15s
authentik-db-2                1/1     Running           0          15s
authentik-db-3-join-w6nwh     0/1     Pending           0          0s
authentik-db-3-join-w6nwh     0/1     Pending           0          0s
authentik-db-3-join-w6nwh     0/1     Pending           0          2s
authentik-db-3-join-w6nwh     0/1     Init:0/1          0          2s
authentik-db-3-join-w6nwh     0/1     Init:0/1          0          21s
authentik-db-3-join-w6nwh     0/1     Init:0/1          0          22s
authentik-db-3-join-w6nwh     0/1     Init:0/1          0          36s
authentik-db-3-join-w6nwh     0/1     PodInitializing   0          37s
authentik-db-3-join-w6nwh     1/1     Running           0          55s
authentik-db-3-join-w6nwh     0/1     Completed         0          80s
authentik-db-3-join-w6nwh     0/1     Completed         0          81s
authentik-db-3-join-w6nwh     0/1     Completed         0          81s
authentik-db-3-join-w6nwh     0/1     Completed         0          82s
authentik-db-3                0/1     Pending           0          0s
authentik-db-3                0/1     Pending           0          0s
authentik-db-3                0/1     Init:0/1          0          0s
authentik-db-3                0/1     Init:0/1          0          8s
authentik-db-3                0/1     Init:0/1          0          9s
authentik-db-3                0/1     Init:0/1          0          9s
authentik-db-3                0/1     PodInitializing   0          10s
authentik-db-3                0/1     Running           0          10s
authentik-db-3                0/1     Running           0          15s
authentik-db-3                0/1     Running           0          18s
authentik-db-3                1/1     Running           0          19s
authentik-db-2-join-97klg     0/1     Completed         0          3m14s
authentik-db-1-initdb-lqvld   0/1     Completed         0          4m37s
authentik-db-3-join-w6nwh     0/1     Completed         0          103s
authentik-db-2-join-97klg     0/1     Completed         0          3m14s
authentik-db-3-join-w6nwh     0/1     Completed         0          103s
authentik-db-1-initdb-lqvld   0/1     Completed         0          4m37s

I also could watch the status of the pods real time in Rancher under Workloads>Pods, here’s a screenshot after it has completed:

Rancher UI

Confirmed cluster health:

kubectl get clusters.postgresql.cnpg.io -n authentik
NAME           AGE     INSTANCES   READY   STATUS                     PRIMARY
authentik-db   7m59s   3           3       Cluster in healthy state   authentik-db-1

kubectl get svc -n authentik
NAME              TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
authentik-db-r    ClusterIP   10.43.40.55     <none>        5432/TCP   8m20s
authentik-db-ro   ClusterIP   10.43.208.99    <none>        5432/TCP   8m20s
authentik-db-rw   ClusterIP   10.43.185.174   <none>        5432/TCP   8m20s

Confirmed the disks are replicated across the cluster:

ls -lah /storage/replicas/*
/storage/replicas/pvc-154dede3-5fa6-4a7e-a1ca-a0649db940bf-fc3e0884:
total 357M
drwx------ 2 root root 4.0K Aug 30 16:57 .
drwxr-xr-x 5 root root 4.0K Aug 30 17:00 ..
-rw-r--r-- 1 root root  10G Aug 30 17:08 volume-head-000.img
-rw-r--r-- 1 root root  126 Aug 30 16:57 volume-head-000.img.meta
-rw-r--r-- 1 root root  161 Aug 30 16:57 volume.meta

/storage/replicas/pvc-28f79c3a-11a1-4cc6-8163-3b99f96608d3-0ea00c1b:
total 308M
drwx------ 2 root root 4.0K Aug 30 17:00 .
drwxr-xr-x 5 root root 4.0K Aug 30 17:00 ..
-rw-r--r-- 1 root root  10G Aug 30 17:07 volume-head-000.img
-rw-r--r-- 1 root root  126 Aug 30 17:00 volume-head-000.img.meta
-rw-r--r-- 1 root root  161 Aug 30 17:00 volume.meta

/storage/replicas/pvc-56585e5b-7d15-4a5e-b22d-f925c669c460-d18b7f65:
total 324M
drwx------ 2 root root 4.0K Aug 30 16:59 .
drwxr-xr-x 5 root root 4.0K Aug 30 17:00 ..
-rw-r--r-- 1 root root  10G Aug 30 17:07 volume-head-000.img
-rw-r--r-- 1 root root  126 Aug 30 16:59 volume-head-000.img.meta
-rw-r--r-- 1 root root  161 Aug 30 16:59 volume.meta

Time to deploy Authentik, with default IngressRoute disabled and built-in Postgres disabled:

authentik:
  secret_key: "MY_SECRETS_ARE_NOT_YOURS"
  postgresql:
    host: "authentik-db-rw.authentik.svc.cluster.local"
    name: "authentik"
    user: "authentik"
    password: "NOT_FOR_YOU_TO_SEE"

postgresql:
  enabled: false

server:
  ingress:
    enabled: false

redis:
  enabled: true
  master:
    persistence:
      storageClass: longhorn
helm repo add authentik https://charts.goauthentik.io
"authentik" has been added to your repositories

helm repo update
Hang tight while we grab the latest from your chart repositories...
...Successfully got an update from the "longhorn" chart repository
...Successfully got an update from the "authentik" chart repository
...Successfully got an update from the "cnpg" chart repository
...Successfully got an update from the "rancher-stable" chart repository
...Successfully got an update from the "jetstack" chart repository
...Successfully got an update from the "projectcalico" chart repository
Update Complete. ⎈Happy Helming!⎈

helm install authentik authentik/authentik \
  -n authentik \
  -f authentik/authentik-values.yaml
NAME: authentik
LAST DEPLOYED: Sun Aug 30 17:16:01 2026
NAMESPACE: authentik
STATUS: deployed
REVISION: 1
TEST SUITE: None

kubectl get pods -n authentik -w
NAME                                READY   STATUS    RESTARTS   AGE
authentik-db-1                      1/1     Running   0          19m
authentik-db-2                      1/1     Running   0          18m
authentik-db-3                      1/1     Running   0          16m
authentik-server-6cc6c997dc-nlp94   0/1     Running   0          2m42s
authentik-worker-c68bfc448-vpzvx    0/1     Running   0          2m42s
authentik-server-6cc6c997dc-nlp94   0/1     Running   0          3m30s
authentik-server-6cc6c997dc-nlp94   1/1     Running   0          3m31s
authentik-worker-c68bfc448-vpzvx    0/1     Running   0          3m41s
authentik-worker-c68bfc448-vpzvx    1/1     Running   0          3m41s

The service is marked as ready in Rancher, time to create an IngressRoute:

kubectl get svc -n authentik
NAME               TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
authentik-db-r     ClusterIP   10.43.40.55     <none>        5432/TCP         29m
authentik-db-ro    ClusterIP   10.43.208.99    <none>        5432/TCP         29m
authentik-db-rw    ClusterIP   10.43.185.174   <none>        5432/TCP         29m
authentik-server   ClusterIP   10.43.175.7     <none>        80/TCP,443/TCP   11m
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: authentik-ui
  namespace: authentik
spec:
  entryPoints:
    - web
    - websecure
  routes:
    - match: Host(`idp.k3s-lab.homeblab.com`)
      kind: Rule
      services:
        - name: authentik-server
          port: 80
kubectl apply -f authentik/authentik-ingressroute.yaml 
ingressroute.traefik.io/authentik-ui created

Visiting https://idp.k3s-lab.homeblab.com gets me to the Authentik setup page, it does not redirect to HTTPS by default, I’ll delete the old Ingress Route and create a new one to redirect to HTTPS automatically

kubectl delete ingressroute authentik-ui -n authentik
ingressroute.traefik.io "authentik-ui" deleted from authentik namespace
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: redirect-https
  namespace: authentik
spec:
  redirectScheme:
    scheme: https
    permanent: true
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: authentik-ui-http
  namespace: authentik
spec:
  entryPoints:
    - web
  routes:
    - match: Host(`idp.k3s-lab.homeblab.com`)
      kind: Rule
      services:
        - name: authentik-server
          port: 80
      middlewares:
        - name: redirect-https
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: authentik-ui-https
  namespace: authentik
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`idp.k3s-lab.homeblab.com`)
      kind: Rule
      services:
        - name: authentik-server
          port: 80
kubectl apply -f authentik/authentik-ingressroute.yaml
middleware.traefik.io/redirect-https created
ingressroute.traefik.io/authentik-ui-http created
ingressroute.traefik.io/authentik-ui-https created

Now visiting Authentik via HTTP always redirects to HTTPS.

And now a PVC for storing Media like backgrounds and logos:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: authentik-media-pvc
  namespace: authentik
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: longhorn
  resources:
    requests:
      storage: 1Gi
kubectl apply -f authentik/authentik-pvc.yaml 
persistentvolumeclaim/authentik-media-pvc created

kubectl get pvc -n authentik
NAME                  STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
authentik-db-1        Bound    pvc-154dede3-5fa6-4a7e-a1ca-a0649db940bf   10Gi       RWO            longhorn       <unset>                 91m
authentik-db-2        Bound    pvc-56585e5b-7d15-4a5e-b22d-f925c669c460   10Gi       RWO            longhorn       <unset>                 89m
authentik-db-3        Bound    pvc-28f79c3a-11a1-4cc6-8163-3b99f96608d3   10Gi       RWO            longhorn       <unset>                 88m
authentik-media-pvc   Bound    pvc-7cfce7d9-58ab-4bcb-ac7a-c13db2e61358   1Gi        RWO            longhorn       <unset>                 18s

Adjust the server: section in the values file:

authentik:
  secret_key: ""
  postgresql:
    host: "authentik-db-rw.authentik.svc.cluster.local"
    name: "authentik"
    user: "authentik"
    password: ""
  storage:
    backend: file

postgresql:
  enabled: false

server:
  securityContext:
    fsGroup: 1000
  ingress:
    enabled: false
  volumes:
    - name: media
      persistentVolumeClaim:
        claimName: authentik-media-pvc
  volumeMounts:
    - name: media
      mountPath: /data
  strategy:
    type: Recreate

redis:
  enabled: true
  master:
    persistence:
      storageClass: longhorn

7. Generating a self signed CA cert for Traefik

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned-bootstrap
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: homeblab-root-ca
  namespace: cert-manager
spec:
  isCA: true
  commonName: homeblab-root-ca
  secretName: homeblab-root-ca-secret
  duration: 87600h
  issuerRef:
    name: selfsigned-bootstrap
    kind: ClusterIssuer
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: homeblab-ca-issuer
spec:
  ca:
    secretName: homeblab-root-ca-secret
kubectl apply -f cert/homeblab-ca.yaml 
clusterissuer.cert-manager.io/selfsigned-bootstrap created
certificate.cert-manager.io/homeblab-root-ca created
clusterissuer.cert-manager.io/homeblab-ca-issuer created

kubectl get certificate -n cert-manager
NAME               READY   SECRET                    AGE
homeblab-root-ca   True    homeblab-root-ca-secret   19s
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-k3s-lab
  namespace: kube-system 
spec:
  secretName: wildcard-k3s-lab-tls
  issuerRef:
    name: homeblab-ca-issuer
    kind: ClusterIssuer
  dnsNames:
    - "*.k3s-lab.homeblab.com"
    - "k3s-lab.homeblab.com"
kubectl apply -f cert/homeblab-wc-cert.yaml 
certificate.cert-manager.io/wildcard-k3s-lab created

kubectl get certificate -n kube-system
NAME               READY   SECRET                 AGE
wildcard-k3s-lab   True    wildcard-k3s-lab-tls   23s
piVersion: traefik.io/v1alpha1
kind: TLSStore
metadata:
  name: default
  namespace: kube-system
spec:
  defaultCertificate:
    secretName: wildcard-k3s-lab-tls
kubectl apply -f cert/traefik.yaml 
tlsstore.traefik.io/default created