Stop Managing Kubernetes Like It’s 2015
You know that feeling when you SSH into a server, run a kubectl command, and 45 minutes later you’ve got no idea what the current state actually is? Yeah. That’s the problem GitOps solves. And ArgoCD is probably the easiest way to stop doing that.
Here’s the thing: GitOps doesn’t mean “put YAML in a repo and call it DevOps.” It means your Git repo is the source of truth. Full stop. When the cluster drifts, ArgoCD yells at you (or fixes it automatically). When you need to change something, you git commit, ArgoCD syncs, and you’ve got an audit trail. Your 2 AM self will thank you when you need to figure out why Postgres doesn’t match what you pushed last week.
On a home lab running k3s, this is perfect. You get the GitOps experience without the complexity tax. Let’s set it up.
Installing ArgoCD on k3s
First, create the namespace and install the ArgoCD Helm chart:
kubectl create namespace argocdhelm repo add argo https://argoproj.github.io/argo-helmhelm repo updatehelm install argocd argo/argo-cd \ --namespace argocd \ --values /tmp/argocd-values.yamlUse this values file (/tmp/argocd-values.yaml):
global: domain: argocd.your.domain.com # Update with your actual domain
server: insecure: false ingress: enabled: true ingressClassName: traefik # or nginx, depends on your ingress hosts: - argocd.your.domain.com tls: - secretName: argocd-tls hosts: - argocd.your.domain.com
configs: secret: # Generate: python3 -c 'import bcrypt; print(bcrypt.hashpw(b"your-password", bcrypt.gensalt(rounds=12)).decode())' argocdServerAdminPassword: '$2a$12$...'
# Optional but recommended: enable notifications + loggingnotifications: enabled: false # Set to true if you have a Discord/Slack webhook
redis: enabled: true
dex: enabled: false # Disable if not using SSOWait 2-3 minutes for pods to spin up:
kubectl get pods -n argocd -wGrab the initial password (if you didn’t set one):
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -dPort-forward to test locally (no ingress yet):
kubectl port-forward -n argocd svc/argocd-server 8080:443# Now: https://localhost:8080 (accept self-signed cert)Log in with username admin and the password you set/retrieved. You’ll see a pretty empty dashboard. Good. That’s because we haven’t told ArgoCD about anything yet.
The Repo Layout: Your Git Source of Truth
This is the hardest part conceptually, but once it clicks, it’s clean. Your repo structure should look like this:
my-homelab-gitops/├── README.md├── argocd/│ ├── projects/│ │ ├── monitoring/│ │ │ ├── namespace.yaml│ │ │ ├── prometheus.yaml│ │ │ ├── grafana.yaml│ │ │ └── kustomization.yaml│ │ ├── storage/│ │ │ ├── longhorn.yaml│ │ │ └── kustomization.yaml│ │ └── apps/│ │ ├── nextcloud.yaml│ │ ├── jellyfin.yaml│ │ └── kustomization.yaml│ ├── secrets/ # sealed-secrets encrypted, more on this below│ │ ├── monitoring-secrets.yaml│ │ └── apps-secrets.yaml│ └── app-of-apps.yaml # The orchestrator├── kustomization/│ ├── base/│ │ ├── nextcloud/│ │ │ ├── deployment.yaml│ │ │ ├── service.yaml│ │ │ └── kustomization.yaml│ │ └── jellyfin/│ │ ├── deployment.yaml│ │ ├── pvc.yaml│ │ └── kustomization.yaml│ └── overlays/│ ├── dev/│ │ ├── kustomization.yaml│ │ └── patches/│ └── prod/│ ├── kustomization.yaml│ └── patches/└── docs/ ├── SETUP.md └── TROUBLESHOOTING.mdThe key insight: argocd/ holds Application manifests (pointers to your YAML). kustomization/ and argocd/secrets/ hold the actual Kubernetes resources. This separation keeps your GitOps config tidy.
The App-of-Apps Pattern: Orchestration Without the Headache
Instead of registering 10 separate ArgoCD Applications one-by-one through the UI (which defeats the purpose of declaring everything), you create a single “meta-application” that manages other applications. It’s application inception.
Create argocd/app-of-apps.yaml:
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: apps namespace: argocdspec: project: default source: repoURL: https://github.com/you/my-homelab-gitops.git targetRevision: main path: argocd/projects destination: server: https://kubernetes.default.svc namespace: argocd syncPolicy: automated: prune: true # Delete resources if they're removed from Git selfHeal: true # Sync if cluster drifts from Git syncOptions: - CreateNamespace=trueApply it once:
kubectl apply -f argocd/app-of-apps.yamlNow add an Application to argocd/projects/monitoring/prometheus-app.yaml:
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: monitoring-prometheus namespace: argocd annotations: argocd.argoproj.io/sync-wave: "0" # Sync firstspec: project: default source: repoURL: https://github.com/you/my-homelab-gitops.git targetRevision: main path: kustomization/base/prometheus destination: server: https://kubernetes.default.svc namespace: monitoring syncPolicy: automated: prune: true selfHeal: true syncOptions: - CreateNamespace=trueCommit and push. ArgoCD will detect the new Application via the app-of-apps, and deploy it automatically. No kubectl apply. No manual steps. Just Git.
Secrets: The Sealed-Secrets Dance
You can’t commit plaintext secrets to Git. That’s a firing offense. But you can encrypt them with Sealed Secrets and commit the encrypted blob. ArgoCD decrypts at sync time.
Install sealed-secrets:
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.24.0/controller.yaml# Wait for controller pod to startkubectl wait --for=condition=ready pod -l app.kubernetes.io/name=sealed-secrets-controller -n kube-system --timeout=300s
# Fetch the sealing keykubeseal --fetch-cert > ~/.kube/sealing-key.crtCreate a secret (plaintext, local):
kubectl create secret generic nextcloud-db-secret \ --from-literal=db-password=your-super-secret-password \ --dry-run=client -o yaml > /tmp/secret.yamlSeal it:
cat /tmp/secret.yaml | kubeseal \ --cert ~/.kube/sealing-key.crt \ --scope namespace \ -o yaml > argocd/secrets/nextcloud-sealed.yamlCommit argocd/secrets/nextcloud-sealed.yaml. The plaintext /tmp/secret.yaml stays local, never touching Git.
Reference the sealed secret in your Application:
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: nextcloud namespace: argocd annotations: argocd.argoproj.io/sync-wave: "1" # After namespace + secrets (wave 0)spec: project: default source: repoURL: https://github.com/you/my-homelab-gitops.git targetRevision: main path: kustomization/base/nextcloud destination: server: https://kubernetes.default.svc namespace: nextcloud syncPolicy: automated: prune: true selfHeal: trueWhen ArgoCD syncs, sealed-secrets controller automatically decrypts the sealed secret back into a working Secret. Clean.
Sync Waves: Orchestrating Deployment Order
You can’t deploy Nextcloud before the database exists. ArgoCD’s sync waves solve that without complex logic.
In your Application manifests, add metadata.annotations:
apiVersion: apps/v1kind: Deploymentmetadata: name: postgres annotations: argocd.argoproj.io/sync-wave: "0" # Deploy firstspec: # ...---# kustomization/base/nextcloud/deployment.yamlapiVersion: apps/v1kind: Deploymentmetadata: name: nextcloud annotations: argocd.argoproj.io/sync-wave: "1" # Deploy after wave 0spec: # ...ArgoCD syncs wave 0, waits for it to be healthy (Pods running), then syncs wave 1. It’s like a forklift that actually understands dependencies—no more “oops, database wasn’t ready yet.”
Your First Application: Making It Real
Let’s deploy something simple. Create kustomization/base/hello-world/deployment.yaml:
apiVersion: v1kind: Namespacemetadata: name: hello-world---apiVersion: apps/v1kind: Deploymentmetadata: name: hello-world namespace: hello-worldspec: replicas: 1 selector: matchLabels: app: hello-world template: metadata: labels: app: hello-world spec: containers: - name: app image: nginx:latest ports: - containerPort: 80---apiVersion: v1kind: Servicemetadata: name: hello-world namespace: hello-worldspec: selector: app: hello-world ports: - port: 80 targetPort: 80 type: ClusterIPCreate kustomization/base/hello-world/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationmetadata: name: hello-worldresources: - deployment.yamlCreate the Application in argocd/projects/apps/hello-world-app.yaml:
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: hello-world namespace: argocdspec: project: default source: repoURL: https://github.com/you/my-homelab-gitops.git targetRevision: main path: kustomization/base/hello-world destination: server: https://kubernetes.default.svc namespace: argocd syncPolicy: automated: prune: true selfHeal: true syncOptions: - CreateNamespace=trueCommit and push:
git add argocd/ kustomization/git commit -m "Add hello-world app"git pushGo to the ArgoCD UI. In ~30 seconds, you’ll see the app sync automatically. Click it to see the tree of Deployments, Services, and Pods. That’s GitOps working.
What GitOps Actually Means at Home Scale
Here’s the un-glamorous truth: GitOps at home scale is boring, and that’s the point.
You’re not running Netflix. You don’t need canary deployments or traffic splitting. What you do need is:
- Repeatable. If your k3s node explodes, you git clone, run the app-of-apps, and 5 minutes later your entire homelab is back. No “wait, did I set that env var?” moments.
- Auditable.
git logtells you when Nextcloud got upgraded, who pushed it, and what changed. Way better than “I think I ran helm upgrade last month.” - Less manual. No SSH-ing into the cluster to fiddle with manifests. You edit YAML locally, push, and ArgoCD does the work.
- Self-healing. If someone (you) accidentally deletes a Deployment, ArgoCD re-applies it from Git within 3 minutes. No silent failures.
You’re trading “just kubectl apply it” for “commit, push, ArgoCD syncs.” Sounds like more work, but it’s not—it’s structured work, and structure scales.
The Optional Upgrades
Once you’re comfortable:
- Notifications: Wire ArgoCD to Discord/Slack. Get a ping when apps sync or drift.
- Multi-cluster: Add a second k3s cluster to
argocd/projects/prod/and deploy different apps there. One Git repo, two clusters. - Image updater: Auto-bump image tags in Git when new versions are pushed to Docker Hub.
argocd-image-updaterhandles that. - Kyverno policies: Enforce “all Deployments must have resource requests” before ArgoCD syncs. Catch mistakes early.
But honestly? You don’t need any of that to start. Just the app-of-apps, sealed-secrets, and sync waves. That’s enough to stop the “SSH into the cluster and pray” flow.
Gotchas (Because There Are Always Gotchas)
- Ingress timeout. ArgoCD server ingress can be finicky on k3s. If the web UI won’t load, check Traefik/Nginx logs. Often a certificate issue.
- Sealed-secrets key loss. Back up that sealing key. If you lose it, you can’t decrypt secrets anymore.
cp ~/.kube/sealing-key.crt ~/backups/sealing-key.crt.gpgand encrypt it locally. - Too much automation. If
prune: trueandselfHeal: trueare both on, and your Git branch gets deleted, ArgoCD will delete your apps. Be intentional.prune: falseis safer for critical stuff. - Kustomize vs Helm. Both work. Kustomize is simpler (it’s just YAML manipulation). Helm is more powerful but noisier. Pick one and stick with it per app.
The Payoff
In a month, you’ll realize you haven’t SSH’d into the cluster in ages. Your homelab Just Works. Git is your single source of truth. When it’s time to upgrade something, you edit YAML, push, and ArgoCD handles the rest.
That’s not fancy. That’s not cloud-native theater. That’s just smart operations. And honestly? On a home lab, that’s all you need.
Ready to try it? Create that GitHub repo, push your first app-of-apps, and watch GitOps just… work. Your future self will send thank-you notes.