A Home Lab on Talos and ArgoCD

Back in 2017 I wrote about my small Kubernetes cluster in OpenFaaS and Kubernetes and Kubernetes Persistent Volumes and RBD. At the time, the nodes were Debian machines that I installed by hand and initialised with kubeadm, and the volumes came from a small Ceph cluster at home. The git repository behind that cluster is still the same one today, with the first commit “Initial import” from September 2017 and about 2900 commits since. Almost everything else has changed.

In this post I will describe how the lab looks now: the nodes run Talos Linux, everything above the nodes is deployed by ArgoCD from the git repository, and storage comes from a TrueNAS box instead of Ceph. I will also go through what actually runs there and what I would still like to fix.

Why Talos

The nodes of the old cluster were general-purpose Linux machines. Each one had its own packages, its own upgrades and its own small differences, and in 2019 I moved the installation to Kubespray so that Ansible would at least keep them consistent. It worked, but upgrading Kubernetes was still a long Ansible run that I postponed as much as possible.

In March 2021 I replaced this with Talos. Talos is a Linux distribution that only runs Kubernetes: there is no shell, no SSH and no package manager. The whole machine is described by one YAML file and managed through an API with talosctl. For a home lab this is exactly what I wanted: a node is something I can reinstall from a file, not something I need to remember how I configured.

The nodes are virtual machines. They first ran on a Proxmox cluster of three physical machines. Today they run on XCP-ng on a single, beefier host. One host uses less power, but it also means that the hardware is now the single point of failure of the whole lab.

The cluster has three control plane nodes and five workers. Three control plane nodes keep etcd and the API available when one of them is down, so I can reboot or upgrade the nodes one at a time without stopping the cluster.

Bootstrapping ArgoCD

Once Kubernetes is running, there is exactly one thing I install by hand:

$ kubectl apply -k argocd

The argocd folder is a kustomization containing the upstream ArgoCD manifest (currently v3.1.0), a Traefik route for the UI, the SSH key to read the repository, and one Application resource for every folder of the repository:

resources:
  - base/install.yaml # from .../argo-cd/v3.1.0/manifests/install.yaml
  - base/ingress-traefik.yaml
  - base/repo-secrets.sops.yaml
  - base/appgroup-default.yaml
  - base/app-argocd.yaml
  - base/app-cert-manager.yaml
  - base/app-forgejo.yaml
  [...]
  - base/app-woodpecker.yaml

This is the “app of apps” pattern, with a small twist: one of the applications is ArgoCD itself, pointing to the same argocd folder.

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: argocd
  namespace: argocd
spec:
  project: default
  source:
    repoURL: 'ssh://git@git.example.org:10022/servers/kubernetes-lab.git'
    path: argocd
    targetRevision: HEAD
  destination:
    server: 'https://kubernetes.default.svc'
    namespace: argocd
  syncPolicy:
    automated: {}

After the manual kubectl apply -k, ArgoCD takes over its own installation. Adding a new service means creating a folder, adding an app-<name>.yaml in argocd/base and pushing. Upgrading ArgoCD means replacing install.yaml with the new upstream manifest and pushing.

Most applications use automated sync with prune and selfHeal. A few of the base components (Traefik, MetalLB, the SMB CSI driver) are still synced manually, because a bad change there takes everything else down with it and I prefer to press the button myself.

Before ArgoCD sees anything, a Woodpecker pipeline renders every application the same way ArgoCD would (kustomize, helm or plain directory), validates the result with kubeconform, and checks that no plain kind: Secret was committed by mistake. Secrets are encrypted in the repository with SOPS and an age key, which will get its own article.

Storage

Storage is the part that changed the most, and the history is a bit embarrassing. The Ceph RBD storage class from my 2017 post stayed in use until 2024, later with the Ceph CSI drivers. In 2023 I moved the media files to TrueNAS, and in 2024 I experimented Mayastor on three workers with a dedicated disk each, to have fast replicated volumes for databases. Ceph was removed in June 2024.

Mayastor did the job, but in the end was more complex than needed, and it’s coming from someone that had a Ceph cluster for a few years. In December 2025 I removed it as well.

Today everything comes from a TrueNAS server through two CSI drivers. The SMB CSI driver provides the smb storage class, used for most volumes.

Since SMB is not a good place for databases, I added a freenas-iscsi-csi storage class from democratic-csi, which creates a zvol on TrueNAS for every volume and mounts it over iSCSI as xfs.

The PostgreSQL databases themselves are managed by CloudNativePG.

Traefik, cert-manager and MetalLB

The cluster runs on my home network, so there is no cloud load balancer. MetalLB has been doing that since 2018. It announces addresses from a dedicated range in layer 2 mode:

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: default-pool
  namespace: metallb-system
spec:
  addresses:
  - 10.0.30.0-10.0.30.255
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: default-advertisement
  namespace: metallb-system
spec:
  ipAddressPools:
  - default-pool

Traefik is installed with its Helm chart and gets a fixed LoadBalancer IP from this pool. All the services use Traefik IngressRoute resources.

The certificates come from cert-manager and Let’s Encrypt. Since my DNS is hosted on Route53, I use the DNS-01 challenge, which has the big advantage of working for wildcard certificates and for hosts that are not reachable from the internet:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-production
  annotations:
    argocd.argoproj.io/sync-wave: "1"
spec:
  acme:
    email: <your-email>
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: letsencrypt-staging
    solvers:
    - dns01:
        route53:
          region: eu-central-1
          accessKeyIDSecretRef:
            name: prod-route53-credentials-secret
            key: access-key-id
          secretAccessKeySecretRef:
            name: prod-route53-credentials-secret
            key: secret-access-key

The sync wave makes sure that ArgoCD only creates the issuer once the cert-manager CRDs and webhook are up. The cert-manager application also needs ServerSideApply=true, because its largest CRD is bigger than the annotation size limit of a client-side apply.

In the Traefik values, a wildcard Certificate for *.lab.example.org is created and set as the default certificate, so most routes only need tls: {}:

traefik:
  tlsStore:
    default:
      defaultCertificate:
        secretName: lab-tls
  extraObjects:
    - apiVersion: cert-manager.io/v1
      kind: Certificate
      metadata:
        name: lab-main
      spec:
        secretName: lab-tls
        dnsNames:
          - '*.lab.example.org'
        issuerRef:
          name: letsencrypt-production
          kind: ClusterIssuer

Services without their own login are protected with the traefik-oidc-auth plugin against Keycloak.

For the few services that must be reachable from the internet, an frp client in the cluster dials out to a small public server and forwards ports 80, 443 and Forgejo’s SSH port to Traefik and Forgejo, so nothing has to be opened on my home router.

What runs there

There are a number of deployed applications, since this is a HomeLab, some are only ever used as deployment experiments.

The ones I use the most:

  • Forgejo for my git repositories, including this one, behind Anubis to keep the scrapers away, and Woodpecker for the CI, running each step as a pod.
  • Immich for the photos, Vaultwarden for the passwords and Keycloak for the logins.
  • A Matrix server with Element for internal chat and chatbots.
  • Monitoring with Grafana, Loki, Mimir, Tempo and Alloy.

Backups of the volumes are done with k8up to S3, which will also get its own article.

Conclusion

Compared to the 2017 cluster, the biggest change is not one tool but the fact that the whole lab is now described in one repository. The nodes come from three Talos files, and everything else comes from git through ArgoCD. When something breaks, I look at the diff instead of trying to remember what I typed.

There is still work to do, and that is part of the fun of a home lab. The backups run every night, but nothing checks automatically that they can actually be restored. And now that all the nodes share a single host, I would like the cluster to start workers when it needs them and stop them when it is idle, instead of keeping every virtual machine running all the time.