🔄 Update — June 2026. ArgoCD no longer watches GitHub but a self-hosted Gitea (internal URL
gitea.gitea.svc.cluster.local:3000, outside Cloudflare Access). The app-of-apps pattern described here is unchanged; only the source of truth has moved. Details: Migrating a GitOps homelab from GitHub to Gitea.
The GitOps principle
The first time I tried a manual kubectl edit on a resource managed by ArgoCD, my change lasted three minutes — the time it took selfHeal to spot the drift from Git and overwrite it back to the state declared in the repository. That was the day “Git as the single source of truth” stopped being a best practice you tick off and became a constraint the system genuinely enforces.
GitOps is an operating practice in which Git is the single source of truth for the state of the infrastructure. Every change goes through a commit — no manual kubectl apply in production. The cluster converges automatically towards the state described in the repository.
This model brings three concrete benefits:
- Auditability: the Git history is the log of every change made to the cluster.
- Reproducibility: rebuilding the cluster from scratch amounts to pointing ArgoCD at the repository.
- Collaboration: pull requests become the review mechanism for infrastructure changes.
Why ArgoCD?
Flux and ArgoCD are the two dominant GitOps tools in the Kubernetes ecosystem. I chose ArgoCD for its web interface, which makes cluster state immediately readable — handy for a homelab where you want to understand quickly what is going on.
ArgoCD continuously watches the configured Git repository and compares the desired state (the YAML manifests) with the actual state of the cluster. Any divergence is reported and can be auto-resolved through the selfHeal policy.
Installation
ArgoCD is bootstrapped manually, once:
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
After that, ArgoCD manages itself through GitOps — the installation manifest is never reapplied by hand.
The App-of-Apps pattern
The App-of-Apps pattern solves a bootstrapping problem: how do you declare several ArgoCD applications in GitOps?
The solution: a “root app” pointing at a directory that contains other Application manifests. ArgoCD creates those applications, which in turn sync their own resources.
Repository structure
k3s-homelab/
├── argocd/
│ ├── bootstrap/
│ │ └── root-app.yaml # The root app — bootstrapped manually
│ └── apps/
│ ├── blog.yaml
│ ├── gitea.yaml
│ ├── longhorn.yaml
│ ├── monitoring.yaml
│ └── ... # One Application per service
├── blog/
│ ├── deployment.yaml
│ └── service.yaml
├── gitea/
│ ├── deploy-gitea.yaml
│ ├── deploy-postgres.yaml
│ └── ...
└── ...
The root app
# argocd/bootstrap/root-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root
namespace: argocd
finalizers: [resources-finalizer.argocd.argoproj.io]
spec:
project: default
source:
repoURL: [email protected]:YellowBeanie/k3s-homelab.git
targetRevision: actual-state
path: argocd/apps
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
A child application (example: Gitea)
# argocd/apps/gitea.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: gitea
namespace: argocd
finalizers: [resources-finalizer.argocd.argoproj.io]
spec:
project: default
source:
repoURL: [email protected]:YellowBeanie/k3s-homelab.git
targetRevision: actual-state
path: gitea
destination:
server: https://kubernetes.default.svc
namespace: gitea
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
Note: the repoURL values above ([email protected]:...) reflect the pre-migration state of this article (end of 2025). Since June 2026, these repositories point to self-hosted Gitea — see Migrating a GitOps homelab from GitHub to Gitea.
prune: true: ArgoCD deletes resources that have been removed from the Git repository. selfHeal: true: if a resource is modified manually in the cluster, ArgoCD puts it back into the Git state automatically. CreateNamespace=true: the namespace is created if it does not exist.
git push sur actual-state est détecté par ArgoCD en ~30 s —
la root app relit argocd/apps/ et propage la synchronisation
en cascade vers chaque Application enfant, puis vers les ressources du cluster.
A git push to actual-state is picked up by ArgoCD in ~30 s —
the root app re-reads argocd/apps/ and propagates the sync
down the cascade to every child Application, then to the cluster resources.
The actual-state branch
One important decision: using a dedicated actual-state branch as the ArgoCD source of truth rather than main.
actual-state is the main working branch. It is where every commit lands, and it is what ArgoCD watches. The main branch is kept as a mirror synchronised through force-push — it always reflects actual-state but is not used directly for deployment.
🔄 Update — June 2026. Since the move to Gitea (see the callout at the top of the article),
actual-statehas become the repository’s single default branch; the oldmainwas retired on 2026-06-27. The force-push mirror mechanism described above is therefore historical.
This convention avoids any confusion: pushing to actual-state = immediate deployment to the cluster.
Daily workflow
The full flow to modify or add a service:
# 1. Make sure you are on the right branch
git checkout actual-state
# 2. Edit the manifests
vim my-service/deployment.yaml
# 3. Commit using the conventional format
git commit -m "fix(my-service): raise resource limits"
# 4. Push → ArgoCD handles the rest
git push
ArgoCD picks up the change in ~30 seconds and syncs automatically. You can follow the deployment in the UI: argocd.olig-homelab-2025.win.
Commit format
type(service): short description
feat(gitea): add PostgreSQL persistence
fix(n8n): correct the DB_HOST env variable
chore(blog): update the Docker image
security(code-server): revoke the exposed token
Active services
| Service | Namespace | URL |
|---|---|---|
| ArgoCD | argocd | argocd.olig-homelab-2025.win |
| Blog | blog | blog.olig-homelab-2025.win |
| Code Server | code-server | code.olig-homelab-2025.win |
| Gitea | gitea | gitea.olig-homelab-2025.win |
| Homepage (decommissioned 2026-06-28, replaced by a custom hub) | — | — |
| Claude WebUI | claude-webui | claude.olig-homelab-2025.win |
| n8n | n8n | n8n.olig-homelab-2025.win |
| Grafana | monitoring | grafana.olig-homelab-2025.win |
| Homelab Admin | homelab-admin | homelab-admin.olig-homelab-2025.win |
| Silex | silex | silex.olig-homelab-2025.win |
| Tapaz (Prsm Studio) | tapaz | tapaz.olig-homelab-2025.win |
Adding a new service
Adding a new service to the cluster comes down to:
- Create a
new-service/directory with the YAML manifests. - Create
argocd/apps/new-service.yamlholding the ArgoCDApplication. - Commit and push to
actual-state. - ArgoCD detects the new file in
argocd/apps/, creates theApplication, then syncs the manifests from the directory.
No manual action on the cluster. The full loop takes ~30 seconds.
Special case: an app whose source lives in another repo
All the services above are manifests + Dockerfile committed directly into k3s-homelab. But some apps have their source code in a dedicated repo — that is the case for tapaz (the Prsm Studio site, in YellowBeanie/prsm-studio).
The risk: falling back into “half-manual” GitOps where, after every release of the source repo, you have to copy the new image digest into k3s-homelab/<app>/deployment.yaml. Three classic solutions:
imagePullPolicy: Always+ mutable tag (:latest) — simple but loses immutability and complicates rollbacks.- ArgoCD Image Updater — a controller that watches GHCR and patches the manifests. Elegant but adds a component to maintain.
- Cross-repo CI — the source repo’s workflow clones
k3s-homelab, patches the digest withsed, commits, pushes. That is what we use fortapaz.
The step added to the source repo’s build-image.yml workflow:
- name: Bump digest in k3s-homelab
env:
DIGEST: ${{ steps.push.outputs.digest }}
SRC_SHA: ${{ github.sha }}
HOMELAB_REPO_TOKEN: ${{ secrets.HOMELAB_REPO_TOKEN }}
run: |
git clone --depth 1 --branch actual-state \
https://x-access-token:${HOMELAB_REPO_TOKEN}@github.com/YellowBeanie/k3s-homelab.git homelab
cd homelab
sed -i -E "s|(image: ghcr\.io/yellowbeanie/tapaz@)sha256:[a-f0-9]+|\1${DIGEST}|" tapaz/deployment.yaml
if git diff --quiet; then echo "no change"; exit 0; fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add tapaz/deployment.yaml
git commit -m "tapaz: bump image to ${DIGEST} (prsm-studio@${SRC_SHA})"
git push origin actual-state
The HOMELAB_REPO_TOKEN secret is a PAT (ideally fine-grained, scope Contents: write limited to k3s-homelab) placed on the source repo with gh secret set HOMELAB_REPO_TOKEN -R YellowBeanie/prsm-studio.
Result: a git push on the source repo triggers the image build and updates the homelab manifests. The digest stays pinned, the git history shows which source commit produced which image, and a rollback is a standard git revert. All the traceability of GitOps, with no extra controller to operate.
Sealed Secrets — handling secrets in GitOps
A classic GitOps problem: secrets cannot be committed in clear text into Git. The solution used here is Bitnami’s Sealed Secrets.
The Sealed Secrets controller runs in the cluster and holds an asymmetric key. The kubeseal CLI encrypts a Kubernetes Secret into a SealedSecret — an object that is perfectly safe to commit into Git. Only the controller can decrypt it.
# Create a secret and encrypt it
kubectl create secret generic my-secret \
--from-literal=password=s3cr3t \
--dry-run=client -o yaml | \
kubeseal --controller-name sealed-secrets-controller \
--controller-namespace kube-system \
--format yaml > sealedsecret.yaml
The resulting SealedSecret is committed into Git. ArgoCD syncs it, and the controller decrypts it to create the native Secret in the cluster.
Lessons learned
ServerSideApply=true is required for some complex resources (CRDs, resources with immutable fields) that cause conflicts with client-side apply. Better to enable it from the start.
Watch out for finalizers: resources-finalizer.argocd.argoproj.io guarantees that ArgoCD cleans up orphaned resources before deleting an Application. Without it, deleting an Application in ArgoCD does not delete the resources in the cluster — which can create zombie resources.
Never modify manually the resources managed by ArgoCD without temporarily disabling selfHeal. Any manual change will be overwritten within the next 3 minutes.