K3s · ArgoCD · Prometheus · Vault · CloudNativePG · Kyverno · AWS
Overview
A production-grade GitOps stack running on a single bare metal machine. Every component is declared in Git and reconciled by ArgoCD using the App of Apps pattern. Secrets flow through HashiCorp Vault to workloads via External Secrets Operator. TLS is automatic via cert-manager and Let's Encrypt with DNS-01 validation against Route 53. The stack is observable, policy-enforced, and reachable by hostname from any device on the network.
Architecture
External traffic arrives at the IP assigned and announced by MetalLB via L2 ARP on the host network. ingress-nginx terminates TLS and routes requests to the appropriate service. ArgoCD watches the private GitHub repository and syncs changes to the cluster automatically. Vault runs as a systemd service on the host and unseals itself on startup via an AWS KMS customer-managed key.
Components
| Layer | Tool | Link |
|---|---|---|
| Kubernetes | K3s v1.35 | k3s.io |
| GitOps | ArgoCD | argo-cd.readthedocs.io |
| Observability | kube-prometheus-stack | prometheus-community/helm-charts |
| TLS | cert-manager | cert-manager.io |
| DNS (External) | AWS Route 53 | aws.amazon.com/route53 |
| Secrets Store | HashiCorp Vault | vaultproject.io |
| Secrets Sync | External Secrets Operator | external-secrets.io |
| Unseal | AWS KMS | aws.amazon.com/kms |
| Database | CloudNativePG | cloudnative-pg.io |
| Policy | Kyverno | kyverno.io |
| Load Balancer | MetalLB | metallb.universe.tf |
| Ingress | ingress-nginx | kubernetes.github.io/ingress-nginx |
| Local DNS | Pi-hole | pi-hole.net |
| OS | Ubuntu 26.04 LTS | ubuntu.com |
Notable Challenges
CRD Annotation Size Limits
Several Helm charts bundle their Custom Resource Definitions in a format that exceeds Kubernetes's 262KB annotation limit, causing installs to fail. The reliable fix is to install CRDs out-of-band using a CRDs-only manifest, then configure the chart to skip CRD management. This applied to kube-prometheus-stack, cert-manager, CloudNativePG, and Kyverno. Applying a full installation bundle instead of a CRDs-only manifest creates a second operator instance, which caused a separate crash loop described below.
Dueling Prometheus Operators
The alertmanager and prometheus StatefulSets entered a persistent crash loop with pods cycling through init, completion, and termination thousands of times without stabilizing. The root cause was two prometheus-operator instances running simultaneously: one deployed by ArgoCD via Helm, and one created when the full prometheus-operator bundle was applied to work around the CRD size limit. Both operators were reconciling the same resources and overwriting each other's changes, including emptying generated secrets immediately after they were patched. Deleting the stray operator from the default namespace resolved the loop immediately.
Bare Metal Load Balancing
K3s's built-in ServiceLB created iptables rules inside the container's network namespace rather than the host's, so external traffic never reached the ingress controller. Switching to MetalLB resolved the forwarding problem, but the initial IP assignment conflicted with the host's existing network interface, causing ARP responses to route through loopback. The fix required assigning MetalLB a dedicated IP outside the DHCP range, adding it as a secondary address on the host interface via Netplan, and specifying the physical interface explicitly in the L2Advertisement. A Kyverno-generated NetworkPolicy was also blocking all ingress to the ingress-nginx namespace, producing an identical connection refused error that required iptables tracing to isolate.
Vault Bootstrap and Auto-Unseal
External Secrets Operator requires Vault to be unsealed before it can sync secrets, and Vault required manual unsealing after every restart. The seal was migrated from Shamir key shares to AWS KMS using a customer-managed key, with a dedicated IAM user scoped to the three KMS actions required for unseal on that specific key. Vault now unseals automatically on startup, eliminating the manual intervention that would otherwise block the rest of the stack from initializing.
Further Reading
What I Learned Building a GitOps Lab at Home
Problems encountered and resolved during the build, including CRD size limits, dueling Prometheus operators, bare metal load balancing, and Vault auto-unseal with AWS KMS.