GitOps repository for the local k3d platform cluster, managed by ArgoCD using the App-of-Apps pattern.
- platform-gitops
The repository uses a three-layer App-of-Apps pattern. A single root application watches the argocd/ directory and creates all other ArgoCD Application and ApplicationSet objects, which in turn manage their respective paths in the repo.
graph TD
root["platform-root<br/><i>argocd/</i>"]
root -->|wave -1| projects["projects.yaml<br/><i>AppProjects</i>"]
root -->|wave 0| self["argocd-self<br/><i>Helm: argo-cd</i>"]
root -->|wave 1| infra["infrastructure<br/><i>infrastructure/</i>"]
root -->|wave 2| tenants["tenants<br/><i>tenants/</i>"]
root -->|wave 3| apps["team-a-apps<br/>team-b-apps"]
infra --> eso["external-secrets<br/><i>Helm</i>"]
infra --> store["secret-store<br/><i>ClusterSecretStore</i>"]
infra --> nginx["ingress-nginx<br/><i>Helm</i>"]
infra --> monitoring["monitoring<br/><i>Helm: kube-prometheus-stack</i>"]
infra --> rollouts["argo-rollouts<br/><i>ApplicationSet</i>"]
infra --> certmgr["cert-manager<br/><i>ApplicationSet</i>"]
infra --> kargo["kargo<br/><i>ApplicationSet</i>"]
tenants --> ta_ns["team-a namespace<br/>quota / limits / rbac"]
tenants --> tb_ns["team-b namespace<br/>quota / limits / rbac"]
apps --> ta_app["team-a-apps<br/><i>apps/team-a-app/</i>"]
apps --> tb_app["team-b-apps<br/><i>apps/team-b-app/</i>"]
| Tool | Purpose | Install |
|---|---|---|
| k3d | Local Kubernetes cluster | brew install k3d |
| kubectl | Cluster interaction | brew install kubectl |
| argocd CLI | App management | brew install argocd |
| helm | Chart rendering | brew install helm |
The cluster must be bootstrapped via platform-bootstrap before applying this repo.
After the cluster is up, initialize ArgoCD with:
make initThis will:
- Prompt for a GitHub PAT and create a repo credential secret in ArgoCD
- Apply the
platformAppProject - Apply the root App-of-Apps, which recursively syncs this repo
The ClusterSecretStore authenticates with Infisical using Universal Auth. Before syncing, create the credentials Secret manually — this is intentionally not committed to git:
kubectl create secret generic infisical-credentials \
--namespace external-secrets \
--from-literal=clientId=<your-client-id> \
--from-literal=clientSecret=<your-client-secret>Get the client ID and secret from Infisical → Organization Settings → Machine Identities → Universal Auth.
platform-gitops/
│
├── argocd/ # ArgoCD objects — synced by platform-root
│ ├── root-app.yaml # Root App-of-Apps; watches argocd/ recursively
│ ├── argocd-self.yaml # ArgoCD self-managed via Helm
│ ├── projects.yaml # AppProjects: platform, team-a, team-b
│ ├── clusters.yaml # In-cluster Secret with feature labels
│ ├── infrastructure-apps.yaml # Application syncing infrastructure/
│ ├── tenants-apps.yaml # Application syncing tenants/
│ └── apps.yaml # Per-team Applications
│
├── infrastructure/ # Platform components (app-of-apps layer)
│ ├── external-secrets.yaml # External Secrets Operator (Helm)
│ ├── secret-store.yaml # Application → infrastructure/secret-store/
│ ├── secret-store/
│ │ └── cluster-secret-store.yaml # ClusterSecretStore: Infisical (mmastersvz-ztk-z/dev)
│ ├── ingress-nginx.yaml # Ingress controller (Helm)
│ ├── monitoring.yaml # kube-prometheus-stack: Prometheus + Grafana + Alertmanager
│ ├── argo-rollouts.yaml # ApplicationSet — conditional on argo-rollouts label
│ ├── cert-manager.yaml # ApplicationSet — conditional on cert-manager label
│ ├── kargo.yaml # ApplicationSet — conditional on kargo label
│ └── kargo-credentials.yaml # ExternalSecret → kargo-admin-credentials Secret
│
├── tenants/ # Per-team namespace setup (platform team manages)
│ ├── team-a/
│ │ ├── namespace.yaml # wave 0
│ │ ├── quota.yaml # wave 1 — 2 CPU / 2Gi req, 4 CPU / 4Gi limit, 10 pods
│ │ ├── limits.yaml # wave 1 — default 200m/256Mi req, 500m/512Mi limit
│ │ └── rbac.yaml # wave 1 — developer Role + RoleBinding
│ └── team-b/
│ ├── namespace.yaml
│ ├── quota.yaml
│ ├── limits.yaml
│ └── rbac.yaml
│
├── apps/ # Team workloads (each team owns their subdirectory)
│ ├── team-a-app/
│ │ ├── deployment.yaml
│ │ ├── service.yaml
│ │ └── hpa.yaml
│ └── team-b-app/
│ ├── deployment.yaml
│ └── service.yaml
│
├── Makefile
└── README.md
Sync waves enforce a deterministic bootstrap order. Resources in lower waves are fully healthy before higher waves begin.
gantt
title ArgoCD Sync Wave Order
dateFormat X
axisFormat wave %s
section argocd/
AppProjects (projects.yaml) :done, w0, -1, 0
argocd-self + root-app :done, w1, 0, 1
infrastructure Application :done, w2, 1, 2
tenants Application :done, w3, 2, 3
team-a-apps + team-b-apps :done, w4, 3, 4
section tenants/
Namespace resources :done, t0, 0, 1
Quota + LimitRange + RBAC :done, t1, 1, 2
section infrastructure/
external-secrets + secret-store :done, i1, 1, 2
ingress-nginx + monitoring :done, i2, 1, 2
argo-rollouts + cert-manager :done, i3, 1, 2
kargo-credentials (ExternalSecret) :done, i4, 29, 30
kargo (ApplicationSet) :done, i5, 30, 31
Key ordering guarantees:
- AppProjects exist before any Application references them (avoids "project not found" on clean bootstrap)
- Namespaces exist before ResourceQuota, LimitRange, and RBAC are applied
external-secretsand theClusterSecretStoreare ready beforekargo-credentialssyncskargo-credentialsSecret exists before the Kargo Helm chart starts (wave 29 → 30)
argo-rollouts, cert-manager, and kargo are ApplicationSet resources that use the cluster generator to conditionally deploy based on cluster labels.
flowchart LR
css["argocd/clusters.yaml\n<i>in-cluster Secret</i>"]
css -->|argo-rollouts: enabled| ar["ApplicationSet\nargo-rollouts"]
css -->|cert-manager: enabled| cm["ApplicationSet\ncert-manager"]
css -->|"kargo: enabled\n*(commented out)*"| kg["ApplicationSet\nkargo"]
ar --> ar_app["Application\nargo-rollouts\n→ argo-rollouts ns"]
cm --> cm_app["Application\ncert-manager\n→ cert-manager ns"]
kg --> kg_app["Application\nkargo\n→ kargo ns"]
Cluster labels are managed in argocd/clusters.yaml. To enable or disable a component, edit that file and push — ArgoCD will create or prune the Application automatically.
# argocd/clusters.yaml
labels:
argo-rollouts: enabled # deployed
cert-manager: enabled # deployed
# kargo: enabled # not deployed — uncomment to enableSecrets are managed via External Secrets Operator backed by Infisical. No secret values are stored in this repository.
flowchart TD
infisical["Infisical\napp.infisical.com\nproject: mmastersvz-ztk-z\nenv: dev"]
subgraph cluster["Kubernetes Cluster"]
mi["Secret: infisical-machine-identity\n<i>ns: external-secrets</i>\n<i>created imperatively — not in git</i>"]
css["ClusterSecretStore: infisical\n<i>Kubernetes Auth</i>"]
es["ExternalSecret: kargo-admin-credentials\n<i>ns: kargo</i>"]
secret["Secret: kargo-admin-credentials\n<i>ns: kargo</i>\nADMIN_ACCOUNT_PASSWORD_HASH\nADMIN_ACCOUNT_TOKEN_SIGNING_KEY"]
kargo["Kargo Helm chart\napi.secret.name:\n kargo-admin-credentials"]
end
mi -->|identityId| css
css -->|Kubernetes Auth| infisical
infisical -->|KARGO_ADMIN_PASSWORD_HASH\nKARGO_ADMIN_TOKEN_SIGNING_KEY| es
es -->|creates| secret
secret -->|mounted by| kargo
All secrets that must exist in the cluster before or during bootstrap, with generation instructions.
Namespace: argocd | Created by: make init (interactive prompt)
ArgoCD needs read access to this repo. Create a fine-grained PAT with Contents: Read on platform-gitops, then run:
make init # prompts for the PAT — does not echo to terminalNamespace: external-secrets | Created by: manual kubectl create secret
The ClusterSecretStore authenticates to Infisical using Kubernetes Auth. The identityId is the UUID shown on the machine identity detail page.
Infisical → Access Control → Machine Identities → <your identity> → copy ID
kubectl create secret generic infisical-machine-identity \
--namespace external-secrets \
--from-literal=identityId=<uuid-from-infisical>This secret is intentionally not in git and must be created before ArgoCD syncs secret-store.
Namespace: kargo | Created by: External Secrets Operator (pulled from Infisical)
The kargo-credentials.yaml ExternalSecret pulls two keys from Infisical and creates this Secret automatically. You must add the values to Infisical first.
KARGO_ADMIN_PASSWORD_HASH — bcrypt hash of your chosen admin password:
# requires: apt install apache2-utils / brew install httpd
htpasswd -bnBC 10 "" your-password | tr -d ':\n'
# or with Python (pip install bcrypt)
python3 -c "import bcrypt; print(bcrypt.hashpw(b'your-password', bcrypt.gensalt(10)).decode())"KARGO_ADMIN_TOKEN_SIGNING_KEY — random string used to sign JWTs (no format requirement):
openssl rand -base64 48Add both values to Infisical under project mmastersvz-ztk-z / environment dev using the exact key names KARGO_ADMIN_PASSWORD_HASH and KARGO_ADMIN_TOKEN_SIGNING_KEY. The ExternalSecret will sync them into the cluster within the configured refreshInterval.
- Add the secret value to Infisical under project
mmastersvz/ environmentdevelopment - Create an
ExternalSecretmanifest referencing theinfisicalClusterSecretStore - Reference the resulting Kubernetes Secret in your workload
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: my-secret
namespace: team-a
spec:
refreshInterval: 1h
secretStoreRef:
name: infisical
kind: ClusterSecretStore
target:
name: my-secret
creationPolicy: Owner
data:
- secretKey: MY_KEY
remoteRef:
key: MY_INFISICAL_SECRET_NAME| Application | Kind | Project | Source | Destination | Condition |
|---|---|---|---|---|---|
platform-root |
Application | platform |
argocd/ |
cluster | always |
infrastructure |
Application | platform |
infrastructure/ |
argocd ns |
always |
tenants |
Application | platform |
tenants/ |
cluster | always |
argocd-self |
Application | platform |
Helm: argo-cd@5.51.6 |
argocd |
always |
ingress-nginx |
Application | platform |
Helm: ingress-nginx@4.9.0 |
ingress-nginx |
always |
monitoring |
Application | platform |
Helm: kube-prometheus-stack@56.6.2 |
monitoring |
always |
external-secrets |
Application | platform |
Helm: external-secrets@0.10.3 |
external-secrets |
always |
secret-store |
Application | platform |
infrastructure/secret-store/ |
external-secrets |
always |
argo-rollouts |
ApplicationSet | platform |
Helm: argo-rollouts@2.40.9 |
argo-rollouts |
argo-rollouts: enabled |
cert-manager |
ApplicationSet | platform |
Helm: cert-manager@1.20.2 |
cert-manager |
cert-manager: enabled |
kargo |
ApplicationSet | platform |
Helm: kargo@1.10.0 |
kargo |
kargo: enabled |
team-a-apps |
Application | team-a |
apps/team-a-app/ |
team-a |
always |
team-b-apps |
Application | team-b |
apps/team-b-app/ |
team-b |
always |
Access is enforced at two independent layers.
Each team namespace has a developer Role and RoleBinding bound to their group. Developers can observe workloads and exec into pods but cannot write resources — all changes go through GitOps.
flowchart LR
user["Developer\n<i>member of team-a group</i>"]
rb["RoleBinding: developer\n<i>ns: team-a</i>"]
role["Role: developer\n<i>ns: team-a</i>"]
user --> rb --> role
role -->|get/list/watch/exec| pods["Pods"]
role -->|get/list/watch| deployments["Deployments\nServices\nConfigMaps\nSecrets\nHPAs\nIngresses"]
To grant a user access:
kubectl create rolebinding <name> \
--role=developer \
--user=<user> \
-n team-aEach team has a dedicated AppProject scoped to their namespace:
| Constraint | team-a / team-b |
|---|---|
| Source repos | this repo only |
| Destination namespaces | team-a / team-b only |
| Cluster-scoped resources | blocked |
| Namespace-scoped resources | Deployment, StatefulSet, DaemonSet, Job, CronJob, HPA, PDB, Ingress, Service, ConfigMap, Secret, ServiceAccount |
| ArgoCD role | proj:<team>:developer — view and sync own apps only |
make pf # start all port-forwards in parallel
make stop-pf # stop all port-forwards
make argocd-pf # ArgoCD UI → http://localhost:9080
make grafana-pf # Grafana → http://localhost:9081
make prometheus-pf # Prometheus → http://localhost:9082
make alertmanager-pf # Alertmanager → http://localhost:9083The k3d load balancer exposes ingress-nginx directly on:
| Port | Protocol |
|---|---|
8080 |
HTTP |
8443 |
HTTPS |
- Add an AppProject to
argocd/projects.yaml(copyteam-b, adjust name/namespace) - Add an Application to
argocd/apps.yamlpointing toapps/<team>-app/ - Create
tenants/<team>/withnamespace.yaml,quota.yaml,limits.yaml,rbac.yaml - Create
apps/<team>-app/with workload manifests - Commit and push — ArgoCD syncs automatically
Add a Helm Application manifest to infrastructure/ and it will be picked up by the infrastructure app on next sync.
- Add a new label to
argocd/clusters.yamlfor the feature flag - Write an
ApplicationSetininfrastructure/using a cluster generator that selects on that label - Enable it on a cluster by adding the label to
argocd/clusters.yamlfor that cluster
# argocd/clusters.yaml
labels:
my-component: enabled# infrastructure/my-component.yaml
spec:
generators:
- clusters:
selector:
matchLabels:
my-component: enabled