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:
- lima-vm
- kubectl
- sops
- flux
- age
All the tools above can be installed manually or using arkade, I myself am comfortable using arkade. For example:
arkade get kubectl flux sopsregarding 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
limactl create template:k3s \
--name=k3s-demo \
--cpus=2 \
--memory=2 \
--disk=20 \
--yesafter the instance is created, the output at the end will show the kubeconfig file that will be used to manage the cluster via kubectl
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
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
$ 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.
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-controllermake 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.
$ 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 passedif you want to check the flux related workload you can use the following command
$ 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 64sat 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
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-ageclusters/k3s-demo/demo-apps/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: demo-appsclusters/k3s-demo/demo-apps/nginx-demo.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: 30081clusters/k3s-demo/demo-apps/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: demo-apps
resources:
- namespace.yaml
- nginx-demo.yaml
# - secret.yamlThe 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
$ kubectl get pods -n demo-apps
NAME READY STATUS RESTARTS AGE
nginx-demo-6fd7fbb8f4-4jhjj 1/1 Running 0 136mEncrypt 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.
mkdir -p secretsadd .gitignore in the secrets folder to avoid the private key we generate from being committed.
secrets/.gitignore
*
!.gitignoreGenerate sops private & public key pair
$ age-keygen -o secrets/flux-age-key.txtif you check the structure of the file flux-age-key.txt it will be like this
# created: 2026-02-20T07:19:42Z
# public key: age1h44rlvy9c2ffytt78msmhjtuyuwtpyc6jje7fgjkg4x7l0hpt5dsfxlf9v
AGE-SECRET-KEY-1ECFASJKZTGVTM0K2GV4QYUXGSKL0S2DG43SWV68GWLC455LAFMYQSLGHQAthere 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.
$ kubectl create secret generic sops-age \
--namespace=flux-system \
--from-file=age.agekey=$(pwd)/secrets/flux-age-key.txt
secret/sops-age createdMake sure the sops-age secret is created
$ kubectl get secret sops-age -n flux-system
NAME TYPE DATA AGE
sops-age Opaque 1 2m28sOk 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
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-agecommit all changes and push and wait until flux reconciles, you can watch with the flux command to be more sure.
$ 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:f33a03f7If already delete pods so the new configuration is applied in the new pod
$ kubectl delete pods -l app=demo-app -n demo-appsEnsuring 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.
$ 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-appsEncrypt secret file with sops
$ sops --encrypt \
--age $(echo $SOPS_PUBLIC_KEY) \
--encrypted-regex '^(data|stringData)$' \
--in-place clusters/k3s-demo/demo-apps/secret.yamlafter that the file will be encrypted like this
register secret
clusters/k3s-demo/demo-apps/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: demo-apps
resources:
- namespace.yaml
- nginx-demo.yaml
- secret.yamlWell 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
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: 30081Commit all changes and push, wait for flux to reconcile, you can watch the log of the init containers earlier with the command
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.localthere 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.