wayanjimmy
ENID

Setup K3s Single Node Cluster, GitOps with Flux

Background #

As I mentioned previously here and here, I’m developing a personal Homelab to support my daily development workflow. One of the important components of this setup is how I manage application deployments to the Kubernetes cluster in a more declarative way using the GitOps concept.

GitOps is a way to manage infrastructure and applications through Git, where the desired state is defined in a repository and tools like Flux will ensure the cluster matches that definition.

There are several popular GitOps tools today such as ArgoCD and Flux. I chose Flux because of its native integration with SOPS for secret encryption, as well as its image automation capability that allows automatic deployment when a new Docker image is pushed to the registry.

However, I can’t share my setup 1:1 because I made the GitHub repo private. So I’m trying to replicate how I did the initial setup, perhaps some friends here are interested in building something similar wqwq.

Requirements #

Install:

All the tools above can be installed manually or using arkade, I myself am comfortable using arkade. For example:

bash
arkade get kubectl flux sops

regarding lima-vm, you can check my previous writing membangun-local-kubernetes-development-dengan-lima-vm.

Setup K3s Single Node #

First create the lima-vm instance

bash
limactl create template:k3s \
    --name=k3s-demo \
    --cpus=2 \
    --memory=2 \
    --disk=20 \
    --yes

after the instance is created, the output at the end will show the kubeconfig file that will be used to manage the cluster via kubectl

bash
export KUBECONFIG="/home/jimbo/.lima/k3s-demo/copied-from-guest/kubeconfig.yaml"
kubectl...

Environment Variable Management #

make sure to export the variable above before executing kubectl related commands, for convenience I suggest using direnv

create a .direnv file like this

bash
export KUBECONFIG="/home/jimbo/.lima/k3s-demo/copied-from-guest/kubeconfig.yaml"

all necessary variables, we can add later as needed in the file above.

Check connection to k3s cluster with kubectl #

Execute the following command

bash
$ kubectl cluster-info
Kubernetes control plane is running at https://127.0.0.1:6443
CoreDNS is running at https://127.0.0.1:6443/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy
Metrics-server is running at https://127.0.0.1:6443/api/v1/namespaces/kube-system/services/https:metrics-server:https/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

Installing Flux CD #

Create a git repo, which later this repo will be the source of truth for the k8s state we manage, here is the example repo I made https://github.com/wayanjimmy/k3s-demo.

Now we need to bootstrap fluxcd to the k3s cluster.

bash
flux bootstrap git \
    --url=ssh://[email protected]/wayanjimmy/k3s-demo.git \
    --private-key-file=/home/jimbo/.ssh/id_ed25519 \
    --branch=main \
    --path=clusters/k3s-demo \
    --components-extra image-reflector-controller,image-automation-controller

make sure the ssh key we use is already registered in github, so the bootstrap process can access the repo, after bootstrap is successful, execute the following command to make sure flux is ready.

bash
$ flux check
 checking prerequisites
 Kubernetes 1.34.4+k3s1 >=1.32.0-0
 checking version in cluster
 distribution: flux-v2.7.5
 bootstrapped: true
 checking controllers
 helm-controller: deployment ready
 ghcr.io/fluxcd/helm-controller:v1.4.5
 image-automation-controller: deployment ready
 ghcr.io/fluxcd/image-automation-controller:v1.0.4
 image-reflector-controller: deployment ready
 ghcr.io/fluxcd/image-reflector-controller:v1.0.4
 kustomize-controller: deployment ready
 ghcr.io/fluxcd/kustomize-controller:v1.7.3
 notification-controller: deployment ready
 ghcr.io/fluxcd/notification-controller:v1.7.5
 source-controller: deployment ready
 ghcr.io/fluxcd/source-controller:v1.7.4
 checking crds
 alerts.notification.toolkit.fluxcd.io/v1beta3
 buckets.source.toolkit.fluxcd.io/v1
 externalartifacts.source.toolkit.fluxcd.io/v1
 gitrepositories.source.toolkit.fluxcd.io/v1
 helmcharts.source.toolkit.fluxcd.io/v1
 helmreleases.helm.toolkit.fluxcd.io/v2
 helmrepositories.source.toolkit.fluxcd.io/v1
 imagepolicies.image.toolkit.fluxcd.io/v1
 imagerepositories.image.toolkit.fluxcd.io/v1
 imageupdateautomations.image.toolkit.fluxcd.io/v1
 kustomizations.kustomize.toolkit.fluxcd.io/v1
 ocirepositories.source.toolkit.fluxcd.io/v1
 providers.notification.toolkit.fluxcd.io/v1beta3
 receivers.notification.toolkit.fluxcd.io/v1
 all checks passed

if you want to check the flux related workload you can use the following command

bash
$ kubectl get pods -n flux-system
NAME                                       READY   STATUS    RESTARTS   AGE
helm-controller-68578f8447-t6v2k           1/1     Running   0          64s
kustomize-controller-7ddfbb5875-jvlvm      1/1     Running   0          64s
notification-controller-6d766f87cf-5lrvf   1/1     Running   0          64s
source-controller-6679d8bdb-dgdgs          1/1     Running   0          64s

at this point flux is installed, continue to the app deployment process.

Testing Deploy Demo Application #

We will write yaml using kustomization, which is a kind of abstraction layer for k8s yaml management, but will not be discussed in this writing.

Create the following file.

clusters/k3s-demo/demo-apps-kustomization.yaml

yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: demo-apps
  namespace: flux-system
spec:
  interval: 1m
  path: ./clusters/k3s-demo/demo-apps
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system
  # decryption:
  #   provider: sops
  #   secretRef:
  #     name: sops-age

clusters/k3s-demo/demo-apps/namespace.yaml

yaml
apiVersion: v1
kind: Namespace
metadata:
  name: demo-apps

clusters/k3s-demo/demo-apps/nginx-demo.yaml

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-demo
  namespace: demo-apps
  labels:
    app: nginx-demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-demo
  template:
    metadata:
      labels:
        app: nginx-demo
    spec:
      containers:
      - name: nginx
        image: nginx:alpine
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-demo
  namespace: demo-apps
spec:
  type: NodePort
  selector:
    app: nginx-demo
  ports:
  - port: 80
    targetPort: 80
    nodePort: 30081

clusters/k3s-demo/demo-apps/kustomization.yaml

yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: demo-apps
resources:
- namespace.yaml
- nginx-demo.yaml
# - secret.yaml

The nginx-demo deployment is already exposed via nodeport so later we can check through that port whether the application runs normally.

Well how to deploy this application through the git ops flow? Flux will periodically, based on the interval clusters/k3s-demo/demo-apps-kustomization.yaml, ensure whether the git repo definition is sync with the actual state.

What we need to do is just commit all these yaml files and push to git, here is the repo condition picture after doing all the files above committed https://github.com/wayanjimmy/k3s-demo/tree/81635f0f880c53a63bf8c8c53752b5d1b873a416/clusters/k3s-demo

Wait a few moments and flux will reconcile its state and the pod will appear

bash
$ kubectl get pods -n demo-apps
NAME                          READY   STATUS    RESTARTS   AGE
nginx-demo-6fd7fbb8f4-4jhjj   1/1     Running   0          136m

Encrypt Secret with SOPS #

Flux has integration with sops that we can use so that secrets committed to the git repo are not naked. For further reference about Flux and SOPS, you can check the writing of Budiman JoJo.

bash
mkdir -p secrets

add .gitignore in the secrets folder to avoid the private key we generate from being committed.

secrets/.gitignore

text
*
!.gitignore

Generate sops private & public key pair

bash
$ age-keygen -o secrets/flux-age-key.txt

if you check the structure of the file flux-age-key.txt it will be like this

text
# created: 2026-02-20T07:19:42Z
# public key: age1h44rlvy9c2ffytt78msmhjtuyuwtpyc6jje7fgjkg4x7l0hpt5dsfxlf9v
AGE-SECRET-KEY-1ECFASJKZTGVTM0K2GV4QYUXGSKL0S2DG43SWV68GWLC455LAFMYQSLGHQA

there is a public key and secret key, public key can be added in the repo so everyone can do encryption, while private will be added as a secret in the k8s cluster, so the cluster can do decryption.

bash
$ kubectl create secret generic sops-age \
     --namespace=flux-system \
     --from-file=age.agekey=$(pwd)/secrets/flux-age-key.txt
secret/sops-age created

Make sure the sops-age secret is created

bash
$ kubectl get secret sops-age -n flux-system
NAME       TYPE     DATA   AGE
sops-age   Opaque   1      2m28s

Ok at this stage we just need to introduce the demo-apps kustomization to recognize sops-age as the key to decrypt secret, remove the comment mark related to decryption that was previously in the comment tag.

clusters/k3s-demo/demo-apps-kustomization.yaml

yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: demo-apps
  namespace: flux-system
spec:
  interval: 1m
  path: ./clusters/k3s-demo/demo-apps
  prune: true
  sourceRef:
    kind: GitRepository
    name: flux-system
  decryption:                       
    provider: sops
    secretRef:                      
      name: sops-age

commit all changes and push and wait until flux reconciles, you can watch with the flux command to be more sure.

bash
$ flux get kustomizations -n flux-system --watch
NAME            REVISION                SUSPENDED       READY   MESSAGE
demo-apps       main@sha1:f33a03f7      False           True    Applied revision: main@sha1:f33a03f7
flux-system     main@sha1:f33a03f7      False   True    Applied revision: main@sha1:f33a03f7

If already delete pods so the new configuration is applied in the new pod

bash
$ kubectl delete pods -l app=demo-app -n demo-apps

Ensuring Pod can Decrypt Secret #

At this point we just need to ensure whether the demo-apps workload can decrypt against the secret that has been encrypted using sops.

Create secret file.

bash
$ cat clusters/k3s-demo/demo-apps/secret.yaml
apiVersion: v1
stringData:
  database: somedatabase
  password: secret
  username: admin
kind: Secret
metadata:
  name: db-credentials
  namespace: demo-apps

Encrypt secret file with sops

bash
$ sops --encrypt \
    --age $(echo $SOPS_PUBLIC_KEY) \
    --encrypted-regex '^(data|stringData)$' \
    --in-place clusters/k3s-demo/demo-apps/secret.yaml

after that the file will be encrypted like this

register secret

clusters/k3s-demo/demo-apps/kustomization.yaml

yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: demo-apps
resources:
- namespace.yaml
- nginx-demo.yaml
- secret.yaml

Well how to make sure the pod can access secret in decrypted state? We can add initContainers which this container will be executed first before the nginx container in nginx-demo is executed.

clusters/k3s-demo/demo-apps/nginx-demo.yaml

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-demo
  namespace: demo-apps
  labels:
    app: nginx-demo
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-demo
  template:
    metadata:
      labels:
        app: nginx-demo
    spec:
      initContainers:                    
      - name: secret-test
        image: busybox:1.36
        command:                         
        - sh
        - -c
        - |
          echo "=== SOPS Secret Decryption Test ==="
          echo "DB_HOST: $DB_HOST"
        envFrom:                         
        - secretRef:                     
            name: db-credentials
      containers:
      - name: nginx
        image: nginx:alpine
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-demo
  namespace: demo-apps
spec:
  type: NodePort
  selector:
    app: nginx-demo
  ports:
  - port: 80
    targetPort: 80
    nodePort: 30081

Commit all changes and push, wait for flux to reconcile, you can watch the log of the init containers earlier with the command

bash
jimbo@ser4:~/.../k3s-demo$ kubectl logs -f -l app=nginx-demo -c secret-test -n demo-apps
=== SOPS Secret Decryption Test ===
DB_HOST: postgres.demo-apps.svc.cluster.local

there you will see that the env DB_HOST can be accessed in decrypted state, while the secret in the git repo is also safe in encrypted state.

At this point the flux setup process with sops is complete!

Conclusion #

So far we have a single-node Kubernetes cluster managed with GitOps using Flux. With this setup, all application deployments can be done just with git push, while secrets remain safe with SOPS encryption.

The combination of k3s + lima-vm is very suitable for tinkering with Homelab without having to have special hardware, everything can run on the laptop we use daily.

Subscribe