Skip to content
Go back

ArgoCD for Home Lab GitOps

By SumGuy 15 min read
ArgoCD for Home Lab GitOps
Contents

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:

Terminal window
kubectl create namespace argocd
helm repo add argo https://argoproj.github.io/argo-helm
helm repo update
helm install argocd argo/argo-cd \
--namespace argocd \
--version 10.6.4 \
--values ./argocd-values.yaml

Pin 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):

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 SSO

Three 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:

Wait 2-3 minutes for pods to spin up:

Terminal window
kubectl get pods -n argocd -w

Grab the initial password (if you didn’t set one):

Terminal window
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d

Port-forward to test locally (no ingress yet):

Terminal window
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.md

The 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/v1alpha1
kind: Application
metadata:
name: apps
namespace: argocd
spec:
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=true

directory.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:

Terminal window
kubectl apply -f argocd/app-of-apps.yaml

Now add an Application to argocd/projects/monitoring/prometheus-app.yaml:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: monitoring-prometheus
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0" # Sync first
spec:
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=true

Commit 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:

Terminal window
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 key
kubeseal --fetch-cert > ~/sealing-key.crt

That 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:

Terminal window
kubectl create secret generic nextcloud-db-secret \
--namespace nextcloud \
--from-literal=db-password=your-super-secret-password \
--dry-run=client -o yaml > /tmp/secret.yaml

Seal it:

Terminal window
kubeseal \
--cert ~/sealing-key.crt \
--scope namespace-wide \
-o yaml < /tmp/secret.yaml > kustomization/base/nextcloud-secrets/nextcloud-sealed.yaml

The --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:

argocd/projects/apps/nextcloud-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: nextcloud-secrets
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0" # Secret lands before the workload
spec:
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/v1alpha1
kind: Application
metadata:
name: nextcloud
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "1" # After the secret
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: true
syncOptions:
- CreateNamespace=true

Keep 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:

kustomization/base/postgres/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
annotations:
argocd.argoproj.io/sync-wave: "0" # Deploy first
spec:
# ...
---
# kustomization/base/nextcloud/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nextcloud
annotations:
argocd.argoproj.io/sync-wave: "1" # Deploy after wave 0
spec:
# ...

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: v1
kind: Namespace
metadata:
name: hello-world
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-world
namespace: hello-world
spec:
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: v1
kind: Service
metadata:
name: hello-world
namespace: hello-world
spec:
selector:
app: hello-world
ports:
- port: 80
targetPort: 80
type: ClusterIP

Create kustomization/base/hello-world/kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
metadata:
name: hello-world
resources:
- deployment.yaml

Create the Application in argocd/projects/apps/hello-world-app.yaml:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: hello-world
namespace: argocd
spec:
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: true

Note 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:

Terminal window
git add argocd/ kustomization/
git commit -m "Add hello-world app"
git push

Go 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:

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:

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)


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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Longhorn vs OpenEBS for k3s Storage
Next Post
Helm Without the YAML Soup

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts