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.
GitOps doesn’t mean “put YAML in a repo and call it DevOps.” It means your Git repo is the source of truth. 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 \ --version 10.6.4 \ --values ./argocd-values.yamlPin the chart version. argo-cd 10.6.4 ships Argo CD v3.5.2 (September 2026). Without --version you get whatever is newest at install time, and the values keys below have moved around more than once between major chart versions.
Use this values file (argocd-values.yaml):
global: domain: argocd.example.com # Update with your actual domain
configs: params: # Terminate TLS at the ingress, let argocd-server speak plain HTTP behind it. # Leave this false with a TLS ingress and you get a redirect loop. server.insecure: true secret: # Generate: htpasswd -nbBC 10 "" "your-password" | tr -d ':\n' | sed 's/$2y/$2a/' argocdServerAdminPassword: '$2a$10$...'
server: ingress: enabled: true ingressClassName: traefik # or nginx, depends on your ingress hostname: argocd.example.com tls: true # boolean, not a list
notifications: enabled: false # Set to true if you have a Discord/Slack webhook
dex: enabled: false # Disable if not using SSOThree keys in there are worth calling out, because getting them wrong is the usual reason a fresh install looks fine and then won’t load in a browser:
server.insecurelives underconfigs.params, not underserver. Aserver.insecurekey at the top of theserverblock is ignored by the chart. Argo CD’s server serves HTTPS by default and redirects HTTP to HTTPS. Put a TLS-terminating ingress in front of that and the ingress talks HTTP to the backend, the backend redirects to HTTPS, and you getERR_TOO_MANY_REDIRECTS. Settingserver.insecure: trueunderconfigs.paramsmakes argocd-server serve plain HTTP so the ingress can own TLS. Your traffic is still encrypted from the browser to the ingress.server.ingress.hostnameis a single string, not ahostslist. Older chart versions took a list. Pass one now and the chart quietly ignores it and falls back toglobal.domain, which works right up until the two disagree.server.ingress.tlsis a boolean. Set it totrueand the chart hardcodes the TLS secret name toargocd-server-tls. There is nosecretNamefield to set here, so point cert-manager or your manual cert atargocd-server-tlsin theargocdnamespace, not at some name you picked.
Wait 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/ # one Application manifest per app, nothing else│ │ ├── monitoring/│ │ │ ├── prometheus-app.yaml│ │ │ └── grafana-app.yaml│ │ ├── storage/│ │ │ └── longhorn-app.yaml│ │ └── apps/│ │ ├── nextcloud-app.yaml│ │ └── jellyfin-app.yaml│ └── app-of-apps.yaml # The orchestrator├── kustomization/│ ├── base/│ │ ├── nextcloud/│ │ │ ├── deployment.yaml│ │ │ ├── service.yaml│ │ │ └── kustomization.yaml│ │ ├── jellyfin/│ │ │ ├── deployment.yaml│ │ │ ├── pvc.yaml│ │ │ └── kustomization.yaml│ │ └── nextcloud-secrets/ # sealed-secrets encrypted, more below│ │ ├── nextcloud-sealed.yaml│ │ └── kustomization.yaml│ └── overlays/│ ├── dev/│ │ ├── kustomization.yaml│ │ └── patches/│ └── prod/│ ├── kustomization.yaml│ └── patches/└── docs/ ├── SETUP.md └── TROUBLESHOOTING.mdThe key insight: argocd/ holds Application manifests and nothing else. They are pointers. kustomization/ holds the actual Kubernetes resources, sealed secrets included. Keep that line clean. The moment a plain Deployment sneaks into argocd/projects/, your app-of-apps starts applying workloads into the argocd namespace and you spend an evening working out why.
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 directory: recurse: true # Without this, ArgoCD ignores the subdirectories 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=truedirectory.recurse: true is the one line everyone forgets. A directory source is not recursive by default, so with path: argocd/projects and every Application sitting one level down in monitoring/, storage/ and apps/, ArgoCD finds zero manifests. The app reports Synced and Healthy with nothing in it, which is the most confusing possible way to fail.
Apply 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 applies the encrypted SealedSecret, then the sealed-secrets controller decrypts it into a real Secret in-cluster.
Install sealed-secrets:
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.39.1/controller.yaml
# Wait for controller pod to start. Note the label: it is `name`, not# `app.kubernetes.io/name`. The release manifest sets only the short form.kubectl wait --for=condition=ready pod \ -l name=sealed-secrets-controller \ -n kube-system --timeout=300s
# Fetch the sealing keykubeseal --fetch-cert > ~/sealing-key.crtThat label matters. The controller’s own manifest labels its pod name: sealed-secrets-controller and nothing else, so a kubectl wait built on app.kubernetes.io/name matches no pods and sits there for the full 300 seconds before failing. You will assume the controller is broken. It is not.
Create a secret (plaintext, local). Set the namespace explicitly:
kubectl create secret generic nextcloud-db-secret \ --namespace nextcloud \ --from-literal=db-password=your-super-secret-password \ --dry-run=client -o yaml > /tmp/secret.yamlSeal it:
kubeseal \ --cert ~/sealing-key.crt \ --scope namespace-wide \ -o yaml < /tmp/secret.yaml > kustomization/base/nextcloud-secrets/nextcloud-sealed.yamlThe --namespace nextcloud on the previous command is not decoration. Sealed Secrets binds the ciphertext to a namespace, and with namespace-wide scope, a SealedSecret sealed for one namespace will not decrypt in another. Leave the namespace off and kubectl stamps whatever your current kubeconfig context points at, usually default. The SealedSecret then lands in nextcloud, the controller refuses it, and the event log says no key could decrypt secret, which sounds like a key problem and is actually a namespace problem.
Commit kustomization/base/nextcloud-secrets/nextcloud-sealed.yaml. The plaintext /tmp/secret.yaml stays local, never touching Git. Delete it when you’re done.
Now give the sealed secret its own Application, and put it in an earlier sync wave than the app that consumes it:
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: nextcloud-secrets namespace: argocd annotations: argocd.argoproj.io/sync-wave: "0" # Secret lands before the workloadspec: project: default source: repoURL: https://github.com/you/my-homelab-gitops.git targetRevision: main path: kustomization/base/nextcloud-secrets destination: server: https://kubernetes.default.svc namespace: nextcloud syncPolicy: automated: prune: true selfHeal: true syncOptions: - CreateNamespace=true---apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: nextcloud namespace: argocd annotations: argocd.argoproj.io/sync-wave: "1" # After the secretspec: 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: true syncOptions: - CreateNamespace=trueKeep CreateNamespace=true on both. Without it, an Application whose destination namespace does not exist yet fails the sync with a namespaces "nextcloud" not found error, and ArgoCD will retry that forever rather than create the namespace for you.
When ArgoCD applies the SealedSecret, the sealed-secrets controller decrypts it into a real Secret in the same namespace. One thing to know: ArgoCD will show the generated Secret as an extra resource it does not manage, because the controller created it, not ArgoCD. That is expected. It is not drift.
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:1.30-alpine # pin it; ArgoCD cannot track drift on :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: hello-world syncPolicy: automated: prune: true selfHeal: trueNote the destination namespace. It has to match the namespace your manifests declare. Point it at argocd while the Deployment says namespace: hello-world and ArgoCD applies into hello-world anyway, then reports the app as OutOfSync forever because the resources are not where the Application says they should be. There is no CreateNamespace=true here because the manifests create the Namespace themselves; use one approach or the other, not both.
Commit and push:
git add argocd/ kustomization/git commit -m "Add hello-world app"git pushGo to the ArgoCD UI and wait. ArgoCD polls Git on a timer, not instantly: timeout.reconciliation defaults to 120 seconds with up to 60 seconds of jitter added, so a pushed commit shows up somewhere inside about three minutes. If that feels slow, either hit Refresh in the UI, run argocd app get hello-world --refresh, or wire a webhook from GitHub to https://argocd.example.com/api/webhook, which drops the delay to a second or two. Once it syncs, click the app to see the tree of Deployments, Services and Pods. That’s GitOps working.
What GitOps Actually Means at Home Scale
GitOps at home scale is boring, and boring is the entire reason to do it.
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. It watches cluster resources, so a deletion usually gets reverted in seconds rather than waiting on the next Git poll. 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)
-
Redirect loop on the ingress. If the UI loads a blank page or the browser reports too many redirects, you almost certainly left
server.insecureunset while terminating TLS at the ingress. See the values file above. Check the Traefik or nginx logs for a 308 loop before you go hunting for certificate problems. -
Sealed-secrets key loss. The file
kubeseal --fetch-certgives you is the public certificate. Losing it costs you nothing; re-fetch it. The thing you cannot lose is the controller’s private key, which lives in a Secret in the cluster:Terminal window kubectl get secret -n kube-system \-l sealedsecrets.bitnami.com/sealed-secrets-key -o yaml > main.keygpg --symmetric --cipher-algo AES256 main.key && shred -u main.keyStore
main.key.gpgsomewhere off the cluster. Renaming a file to.gpgdoes not encrypt it. The label selector grabs every key the controller holds, old ones included, but the controller renews its sealing key every 30 days, so redo the backup after each renewal or it won’t cover your newest secrets. Without those keys, every SealedSecret in Git is unreadable and you get to regenerate every password from scratch. -
Too much automation. With
prune: true, deleting a manifest from Git deletes the live resource on the next sync, which is the point, and is also how a badgit rmtakes out your database PVC. Addprune: falsefor anything holding state. Note that an unreachable repo or a deleted branch does not trigger a prune; ArgoCD reports a comparison error and leaves the cluster alone. The danger is a successful sync of a repo you did not mean to change. -
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.
Nothing fancy about it. 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.
Common Questions
Do I need a private Git repo for ArgoCD?
No. A public GitHub repo works fine as long as you never commit plaintext secrets, which is what Sealed Secrets is for. ArgoCD only needs read access. Use a private repo with a deploy key if your manifests reveal internal hostnames, IP ranges or service topology you’d rather not publish.
How much does ArgoCD cost to run on a small cluster?
The default install runs about seven pods: server, repo-server, application controller, applicationset controller, Redis, and the dex and notifications components if you enable them. Expect roughly 700MB to 1GB of memory across all of them at idle. That is real on a 3-node lab of 8GB mini PCs but not painful.
Why does my app show OutOfSync when nothing changed?
Usually a mutating admission controller or a defaulting webhook rewrote the live resource after ArgoCD applied it. Compare the diff in the UI: if the difference is a field you never set, add ignoreDifferences for that path in the Application spec rather than fighting it. Metrics-server and service mesh sidecars cause this constantly.
Can I use ArgoCD with Helm charts instead of Kustomize?
Yes. Set source.chart and source.repoURL to a Helm repository, or point source.path at a directory containing a Chart.yaml. ArgoCD renders the chart with helm template and applies the output, so Helm hooks and helm rollback do not work. Sync waves replace hooks.
What happens to my apps if ArgoCD itself goes down?
Nothing. ArgoCD is a control loop, not a runtime dependency. Your workloads keep running exactly as they are. You lose drift detection, self-heal and new syncs until it comes back, which is why installing ArgoCD via Helm rather than managing it with itself keeps recovery simple.