GitOps from scratch: ArgoCD + self-hosted Gitea on a Raspberry Pi cluster

DevOps Engineer with a proven track record of streamlining software development and delivery processes. Skilled in automation, configuration management, and continuous integration and delivery (CI/CD), with expertise in cloud infrastructure and containerization technologies. Possess strong communication and collaboration skills, able to work effectively across development, operations, and business teams to achieve common goals. Dedicated to staying current with the latest technologies and tools in the DevOps field to drive continuous improvement and innovation.
Previously, I ripped out ingress-nginx and replaced it with Envoy Gateway and Gateway API. This week, I stopped using kubectl apply to deploy things.
That's not an exaggeration; every change to my cluster — scaling a deployment, updating a Helm value, even modifying ArgoCD's own configuration flows through a Git commit. Push to Gitea, ArgoCD picks it up, cluster converges. No SSH. No imperative commands. If it's not in Git, it doesn't exist. If you want the full backstory on the cluster and the Gateway API migration, start with Part 1.
Why self-hosted Gitea instead of GitHub?
GitHub is where I push the backup. But the Git repo that ArgoCD actually watches is hosted on Gitea, running inside the same cluster it's deploying to. There's a reason for that.
The whole point of this project is to demonstrate a production-like workflow on infrastructure I fully control. Having the Git server, the CD controller, and the workloads all on the same cluster means I own every link in the chain. It also means Gitea managing itself through ArgoCD - which is exactly the kind of recursive GitOps setup you'd see in a real platform team.
Plus, Gitea is lightweight enough to run comfortably on a Pi. It ships with PostgreSQL and Valkey (the Redis fork), and the whole stack comes up in under four minutes.
I exposed Gitea through the same Envoy Gateway that I set up previously — an HTTPRoute for HTTPS traffic and another for the HTTP-to-HTTPS redirect. Browsing to gitea.webdemoapp.com gives you the full Gitea UI behind a valid Let's Encrypt certificate.
Podinfo: the demo workload
Every GitOps setup needs an application to actually deploy. I went with Podinfo — it's a purpose-built demo microservice with built-in Prometheus metrics, tracing support, and a web UI that tells you exactly which pod is serving your request. Perfect for screenshots.
Two replicas, spread across the two worker nodes using topologySpreadConstraints. One pod on k8s-worker-1, one on k8s-worker-2.
Installing ArgoCD
ArgoCD went in via Helm with one critical setting: server.insecure: true. And if you're following along, pay attention to where you set this — I burned time on it.
The argo-cd Helm chart moved this setting in recent versions. The old --set server.insecure=true flag does absolutely nothing now. The chart silently ignores it. The correct key is configs.params.server.insecure: true, which populates the argocd-cmd-params-cm ConfigMap. Without it, the ArgoCD server tries to terminate TLS itself, and since Envoy Gateway is already doing that, you get an infinite redirect loop.
I exposed ArgoCD the same way as Gitea — a Gateway API HTTPRoute pointing at argocd-server on port 80 (because insecure mode serves plain HTTP). The argocd CLI also needs the --grpc-web flag for every command when you're going through a gateway, since standard gRPC (HTTP/2) doesn't pass through the proxy cleanly.
Connecting ArgoCD to Gitea
To let ArgoCD pull from the Gitea repo, I generated an access token in Gitea's UI and registered the repo:
Multi-source Applications: chart from registry, values from Git
This is where things got interesting. Each ArgoCD Application uses two sources: the Helm chart comes from the upstream registry (OCI for Podinfo, HTTPS for Gitea), and the values file comes from the Gitea Git repo. ArgoCD renders the chart with your values and applies the result. Change a value in Git, push, and the cluster updates.
The initial sync wasn't smooth. ArgoCD showed both apps as "Unknown" with a ComparisonError because the values file path in the Application manifest didn't match the actual repo structure. The fix was straightforward once I found it — my values files were under gateway-migration/gitea/values.yaml, not the project1/gitea/values.yaml I'd originally assumed.
After fixing the paths and syncing, both apps went green.
App-of-Apps: the last kubectl apply I'll ever run
The App-of-Apps pattern is a root Application that watches a directory of other Application manifests. Mine points at GitOps/apps/ in the Gitea repo. Every YAML file in that directory becomes a managed ArgoCD Application.
The root app itself has selfHeal: true, which means any manual change to a child Application spec gets reverted to match Git. I discovered this in an entertaining way — I kept running kubectl patch to fix an ignoreDifferences block, and the root app kept reverting my patches within seconds. Took me a few rounds to realise that pushing to Git is now the only way to change anything. Which is, of course, the entire point.
RBAC and AppProject
I created a homelab AppProject that restricts which source repos and cluster namespaces the applications can target. It also defines a read-only role — anyone with that token can view apps and logs, but cannot trigger a sync.
Testing the read-only role was the best validation: generate a token, try to sync, get denied.
Gitea webhook: push-to-deploy in seconds
Without a webhook, ArgoCD polls the Git repo every three minutes. With one, it reacts to pushes within seconds. Setting this up in Gitea required one configuration change I didn't expect: Gitea blocks webhooks to private IPs by default (SSRF protection). Since ArgoCD lives at 10.0.0.231, I had to add ALLOWED_HOST_LIST: private under the webhook section of the Gitea Helm values.
The end-to-end test
This is the moment everything built toward. I edited replicaCount: 3 in the podinfo values file, committed, pushed to Gitea, and watched.
The webhook fired. ArgoCD detected the change. The automated sync policy kicked in. Podinfo scaled from two replicas to three — all without touching kubectl.
The GitOps control loop
Here's how the whole system works now:
The key idea in one sentence: Git is the source of truth, not the cluster.
If someone runs kubectl scale to change the replica count directly on the cluster, ArgoCD detects the drift and reverts it to match what's in Git. The cluster state is declarative, versioned, and auditable. Every change has a commit hash attached to it.
What I actually learned during this project
The blog-friendly version of this week is "I set up GitOps." The real version involved a lot of debugging that taught me more than any tutorial would have.
The server.insecure trap. The argo-cd Helm chart silently ignores the old --set server.insecure=true flag. The setting moved to configs.params and you need to escape the dot in --set syntax. I only caught it because the redirect loop was the exact symptom of the server still doing TLS.
YAML indentation matters more than you think. I put ignoreDifferences under syncPolicy instead of as a sibling of it under spec. YAML parsed it fine. Kubernetes accepted it fine. But the CRD's structural schema silently pruned it as an unknown field. The --dry-run=server flag would have caught it, and I'll be using it from now on.
Push ≠ apply. Until the App-of-Apps root application exists, pushing to Git does nothing to the cluster. I spent a few rounds editing files, pushing, and wondering why the live Application spec hadn't changed. The answer was obvious in hindsight: nothing was watching that directory yet.
Self-heal is real. Once the root app has selfHeal: true, you genuinely cannot kubectl patch your way around a problem anymore. The patch lands, the root app detects the drift, and it reverts your change within seconds. This is exactly the behaviour you want in production — but it catches you off guard the first time.
What's next
GitHub Actions CI pipeline with Trivy vulnerability scanning, ArgoCD Image Updater to auto-commit new image tags to Gitea, and Argo Rollouts for canary deployments. Then Prometheus and Grafana go in, so I can actually see what the cluster is doing.
If you want to follow along or dig into the manifests, the repo is public: github.com/charliepoker/homelab-k8s-gitops
Thank You!



