Argo CD Entrypoints¶
The review map for everything directly rendered by the root Application from infrastructure/controllers/argocd/apps/.
root.yaml is the manual seed and is not self-managed. Once it exists, Argo CD manages this entrypoint tree, including file moves inside apps/, ApplicationSet edits, and AppProject edits.
Layout¶
| Folder | Purpose |
|---|---|
bootstrap/ |
Wave 0 foundation apps required before the rest of GitOps can converge |
core-dependencies/ |
Storage, restore, and bootstrap services that later apps depend on |
custom-entrypoints/ |
Intentional standalone apps kept out of AppSets for ordering or render behavior |
appsets/ |
Broad directory discovery for infrastructure, databases, monitoring, and user apps |
Entrypoint Table¶
| Entrypoint | Type | Wave | Reason | Can move to AppSet? |
|---|---|---|---|---|
projects.yaml |
AppProjects | 0 | Project grouping and homelab trust boundary | No, foundational Argo CD config |
bootstrap/argocd.yaml |
Application | 0 | Self-manages the Argo CD Helm chart and values | No, self-management entrypoint |
bootstrap/cilium-app.yaml |
Application | 0 | CNI and Gateway API foundation | No, must be healthy before pods and routes |
bootstrap/1passwordconnect.yaml |
Application | 0 | Secret backend for External Secrets | No, secret dependency for later waves |
bootstrap/external-secrets.yaml |
Application | 0 | ExternalSecret CRDs and controller | No, CRDs are required by downstream apps |
core-dependencies/cert-manager-app.yaml |
Application | 1 | Certificate controller required before webhook-cert consumers in later waves | No, required before cert-dependent apps |
core-dependencies/longhorn-app.yaml |
Application | 1 | Storage foundation before PVC consumers | No, required before restore/app PVC flows |
core-dependencies/snapshot-controller-app.yaml |
Application | 1 | VolumeSnapshot CRDs and controller | No, required by backup/restore flows |
core-dependencies/kopiur-operator-app.yaml |
Application | 2 | kopiur operator (Kopia-native backup): CRDs + controller + webhook + volume populator; no monitoring dependency | No, required before managed app PVCs |
core-dependencies/kopiur-config-app.yaml |
Application | 3 | kopiur repo config: ClusterRepository cluster-kopia + credential fanout + VolumeSnapshotClass longhorn-snapclass |
No, required before managed app PVCs |
custom-entrypoints/keda-app.yaml |
Application | 4 | Standalone to isolate its render from the AppSet generator | Maybe, if AppSet render stays stable |
custom-entrypoints/vertical-pod-autoscaler-app.yaml |
Application | 4 | VPA controller (recommender/updater/admission) | Maybe, if AppSet render stays stable |
custom-entrypoints/vertical-pod-autoscaler-observability-app.yaml |
Application | 6 | Optional VPA PodMonitor and alerts after monitoring CRDs exist | No, keeps observability out of core |
custom-entrypoints/temporal-worker-controller-app.yaml |
Application | 4 | Standalone to isolate its render from the AppSet generator | Maybe, if AppSet render stays stable |
custom-entrypoints/strimzi-app.yaml |
Application | 4 | Source lives at my-apps/development/strimzi (excluded from the my-apps AppSet). Kafka CRDs/operator must precede wave-6 Kafka resources; standalone destination kafka also avoids the AppSet basename→namespace assumption (strimzi would be wrong) |
No, both ordering and destination namespace are exceptional |
custom-entrypoints/opentelemetry-operator-app.yaml |
Application | 6 | Optional telemetry alongside workloads; health cannot gate workload discovery | Maybe; preserve its bootstrap independence |
custom-entrypoints/keda-observability-app.yaml |
Application | 6 | Optional KEDA ServiceMonitor resources after monitoring CRDs exist | No, keeps observability out of core |
custom-entrypoints/vpa-system-policies-app.yaml |
Application | 6 | VPA policies for the small set of bootstrap/system workloads without a co-located owner | No, ownership exception is explicit |
custom-entrypoints/opentelemetry-operator-observability-app.yaml |
Application | 6 | Optional OpenTelemetry ServiceMonitor after monitoring CRDs exist | No, keeps observability out of core |
appsets/infrastructure-appset.yaml |
ApplicationSet | 4 | Explicit list of core infrastructure directories | N/A |
appsets/database-appset.yaml |
ApplicationSet | 4 | Discovers infrastructure/database/*/* (Redis + shared DB support); fully automated since the CNPG retirement (2026-08-13) |
N/A |
appsets/monitoring-appset.yaml |
ApplicationSet | 5 | Discovers monitoring/*/* (metrics/, logs/, tracing/, ai-ops/) after core infra, excluding monitoring/*/_archive |
N/A |
appsets/my-apps-appset.yaml |
ApplicationSet | 6 | Discovers my-apps/*/*, excluding Components, my-apps/*/_archive and my-apps/development/strimzi; all automated + self-healing |
N/A |
Generated Application identities are domain-prefixed:
infrastructure-<component>, database-<database>, monitoring-<component>,
and my-apps-<app>. Namespaces still use the leaf directory name. The prefix
prevents operationally dangerous UI ambiguity such as confusing the Temporal
database Application with the Temporal server/schema Application.
ApplicationSet waves order creation of the generator objects, not the health of their generated Applications. Those children reconcile independently. See wave scope before treating an AppSet wave as a dependency barrier.
Bootstrap guardrail — observability is not a core dependency¶
A bootstrap-critical app must render successfully without monitoring.coreos.com CRDs. Argo's Helm chart conditionally renders its monitors only when the installed API capability exists. Other
monitoring resources belong in optional observability overlays. Those CRDs do not exist until
kube-prometheus-stack (Wave 5); an earlier-wave app shipping them fails dry-run and deadlocks the
App-of-Apps wave gate. Put observability CRs in a separate optional app that syncs after Wave 5
(e.g. keda-observability at Wave 6, split out of KEDA's Wave-4 core). Do not install Prometheus
Operator CRDs early — SkipDryRunOnMissingResource is an escape hatch / observability-app option only,
never a core fix. cert-manager is at Wave 1 (not 4) so early cert-dependent apps can start
(historically the Wave-3 cnpg-barman-plugin; the deadlock lesson stands even though that app is
retired). Full detail: cluster DR nuke restore runbook.
Bootstrap guardrail — Application health does not prove webhook reachability¶
Waves gate on Argo CD Application health. For a webhook Deployment that means
ready replicas == desired — and kubelet decides readiness by probing the pod
locally, on its own node. Neither signal involves the API server. A webhook
pod can therefore be Running, Ready, and its Application Healthy, while the
API server cannot reach it across the pod network. Every later wave then proceeds
into a webhook that cannot be called.
The blast radius is set by failurePolicy, not by the size of the component. A
cluster-scoped failurePolicy: Fail webhook rejects every matching write in
the cluster while unreachable, and a sync can fail after applying only part of its resources. Unrelated apps
can stall waiting for Secrets or other objects rejected by admission.
Two rules follow:
- Fail-closed cluster-scoped webhooks are pinned to the control plane
(
nodeSelector: node-role.kubernetes.io/control-planeplus the matchingNoScheduletoleration). The API server calls them node-locally, so no pod-network fault on any other node can break admission. This applies tocert-manager. Availability is then coupled to the API server's own availability, which is the correct coupling: if the control plane is down, admission is moot. - Do not add a custom Lua health check to probe webhook reachability. Argo CD
configuration stays simple; the reachability signal belongs in alerting
(
monitoring/metrics/prometheus-stack/admission-webhook-alerts.yaml, which reads the API server's ownapiserver_admission_webhook_*metrics), not in the wave gate.
Notes¶
project-nomadis intentionally managed byappsets/my-apps-appset.yamlas a single bundled app atmy-apps/knowledge/project-nomad. Its child folders are resources inside that app, not generated Argo CD Applications.my-apps/common/*is excluded from the my-apps generator: those directories are shared Kustomize Components (kind: Component), which kustomize builds as an empty render — without the exclude the AppSet generates a phantom zero-resource Application.validate-argocd-apps.shCheck 8 fails CI if a Component dir ever becomes discoverable again.my-apps/development/strimziis excluded from the my-apps generator: the files live with the dev tools, butcustom-entrypoints/strimzi-app.yamldeploys them at wave 4 into namespacekafka.- Category folders are for people only. Application names and namespaces come from the last folder, so moving an app between categories (for example
my-apps/media/x→my-apps/utility/x) only changes its source path. Renaming the last folder creates a new Application and namespace. - The infrastructure and monitoring ApplicationSets set
preserveResourcesOnDeletion: true, so their generated Applications carry noresources-finalizer. If a path briefly stops matching (a folder move, a typo, the AppSet spec and repo updating out of order), Argo CD removes only the Application object and recreates it on the next generation; workloads, namespaces and PVCs keep running. The trade-off: removing an infra or monitoring component from git leaves its resources on the cluster. Delete them by hand after the Application is gone; they still carry theargocd.argoproj.io/tracking-id: <app>:…annotation, andkubectl delete -kagainst the path at the previous commit removes them. Re-adding the finalizer does not work because the AppSet controller rewrites finalizers on every update. The my-apps and database ApplicationSets keep the finalizer because_archive/relies on the cascade. - Change an ApplicationSet's paths and move the folders it matches in separate PRs: first list the new paths alongside the old ones, then move the folders, then drop the old paths. Doing both in one commit let the controller read the new folders with the old spec. Wait for each step to be live, not just merged: root walks the sync waves and can take ~20 minutes to apply an AppSet change. Use the read-only live prerequisite gate: require
operationState.phase: SucceededwithoperationState.syncResult.revisionat the merged commit, then verify the exact live AppSet fields, generated Application ownership/finalizers and preserved resource UIDs.status.sync.revisionalone is comparison evidence, not proof that the safety change applied. The user must explicitly authorize each specific PR merge. - All four ApplicationSets use strict Go templates (
missingkey=error). Git directory fields are objects in Go-template mode. The leading.means "the current generator result";pathis the Git generator's directory object, and the remaining names select fields from it. Therefore use{{ .path.path }}/{{ .path.basename }}rather than the deprecated fasttemplate forms{{ path }}/{{ path.basename }}. Strict missing-key handling deliberately fails generation instead of silently producing an Application with an empty name, source path, or namespace. The render CI also rejects any non-Component kustomization that produces zero Kubernetes objects. - Changing a generated Application name replaces the Application object. Never merge an identity rename into a running cluster without a staged finalizer migration. The 2026-07-31 prefix migration is complete; its historical merge-after-destruction step is not part of a normal rebuild.
rustfs-lifecycleis not a standalone Argo entrypoint. It is generated byappsets/infrastructure-appset.yamlfrominfrastructure/backup/rustfs-lifecycleat wave 4.- Global ignore rules for HTTPRoute, ExternalSecret, and PVC restore fields live in
infrastructure/controllers/argocd/values.yaml; AppSets should only carry app-specific ignore rules. - AppProjects are intentionally permissive for a single-operator homelab. They are labels and UI grouping, not hard tenant guardrails.
- Hook Jobs are acceptable when they are idempotent, bounded, and documented. The RustFS lifecycle Job is a PostSync hook because S3 lifecycle PUTs are idempotent and Kubernetes Jobs are immutable.
Project Nomad App Boundary¶
Project Nomad is not special to Argo CD; it is special only in repo shape. The my-apps ApplicationSet discovers app directories with my-apps/*/*, so my-apps/knowledge/project-nomad is the generated Application boundary.
Inside that directory there is one parent kustomization.yaml. Subdirectories such as mysql/, redis/, qdrant/, embeddings/, kiwix/, protomaps/, cyberchef/, and flatnotes/ are resource folders referenced by the parent kustomization, not independent app directories.
Do not exclude my-apps/knowledge/project-nomad/*; that pattern targets child folders the AppSet does not generate. If Project Nomad should ever become multiple Argo CD Applications, add child kustomization.yaml files deliberately and update the generator/validation model at the same time.
Archived apps¶
A retired app moves into its category's _archive/ folder, for example
my-apps/ai/_archive/ninfer/ or monitoring/ai-ops/_archive/holmesgpt/. The
my-apps and monitoring ApplicationSets exclude */_archive, so nothing there
is deployed.
Archive an app
- Confirm the latest backup succeeded:
kubectl -n <ns> get snapshot. git mv my-apps/<category>/<app> my-apps/<category>/_archive/<app>and merge.
Expected result for my-apps: Argo CD deletes the Application, its namespace and
its PVCs through the resources finalizer. Monitoring Applications are removed
but their resources remain because that ApplicationSet enables preservation;
manual cleanup requires explicit destructive authorization. Kopia snapshots stay
in the repository with onNamespaceDelete: Orphan (the current ClusterRepository
CRD default); do not change that setting, or archiving an app
deletes its backup history.
Bring an app back (rollback)
git mv the folder back up one level and merge. The Application is recreated
and restore-before-bind fills each PVC from its latest snapshot, the same path
a disaster-recovery rebuild uses.
Leave archived files unchanged. They sit one folder deeper, so their
../../common/kopiur-backup link does not resolve there; CI skips _archive/
when rendering, and the link works again once the folder moves back.