From manual kubectl commands to fully automated infrastructure management - here's how I built a production-ready GitOps pipeline
TL;DR
I built a complete GitOps infrastructure management system using:
- π― Argo CD for GitOps automation
- β‘ Crossplane for infrastructure provisioning
- π App-of-Apps pattern for scalable application management
- π¦ MetalLB as the infrastructure example
- π Sync waves for dependency management
Result: Infrastructure changes now happen through Git commits, with full automation and zero manual intervention.
The Problem I Solved
Managing Kubernetes infrastructure traditionally sucks:
# The old way - manual and error-prone kubectl apply -f metallb-config.yaml kubectl apply -f ingress-controller.yaml kubectl apply -f monitoring-stack.yaml # Oh no! Order matters... π₯ # Which version is running in production? π€·ββοΈ # Who made that change? π΅οΈββοΈ
I wanted infrastructure that:
- β Lives in Git (version controlled)
- β Deploys automatically (no manual steps)
- β Handles dependencies (no ordering issues)
- β Self-heals (drift detection & correction)
- β Provides audit trails (who, what, when)
The Solution Architecture
graph LR A[Git Commit] --> B[Argo CD] B --> C[Crossplane] C --> D[Kubernetes Resources] B --> E[Sync Waves] E --> F[Ordered Deployment]
π― Step 1: App-of-Apps Pattern
Instead of managing 50+ individual Argo CD applications, I use the App-of-Apps pattern:
# One app to rule them all apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: homelab-root annotations: argocd.argoproj.io/sync-wave: "-1" spec: source: repoURL: https://github.com/jamilshaikh07/homelab-gitops.git path: apps # π All child apps live here syncPolicy: automated: prune: true # ποΈ Clean up deleted resources selfHeal: true # π§ Fix manual changes
Benefits:
- One root app manages everything
- New apps = just add YAML files
- Automatic discovery and deployment
β‘ Step 2: Crossplane for Infrastructure
Crossplane lets me manage infrastructure through Kubernetes APIs. Here's the magic:
# Instead of direct kubectl apply... apiVersion: kubernetes.crossplane.io/v1alpha1 kind: Object metadata: name: metallb-ipaddresspool annotations: argocd.argoproj.io/sync-wave: "2" # π Depends on provider spec: providerConfigRef: name: in-cluster forProvider: manifest: apiVersion: metallb.io/v1beta1 kind: IPAddressPool metadata: name: homelab-pool namespace: metallb-system spec: addresses: - 10.20.0.81-10.20.0.99 # π― My LoadBalancer IP range
Why Crossplane Objects?
- π Continuous reconciliation (drift detection)
- π Rich status reporting (health, errors)
- π Dependency management (waits for providers)
- π RBAC integration (secure access)
π Step 3: Sync Waves for Dependencies
Order matters in infrastructure! I use sync waves to ensure proper sequencing:
# Wave -1: Root app-of-apps argocd.argoproj.io/sync-wave: "-1" # Wave 0: Install Crossplane providers argocd.argoproj.io/sync-wave: "0" # Wave 1: Provider configs + RBAC argocd.argoproj.io/sync-wave: "1" # Wave 2: Infrastructure resources argocd.argoproj.io/sync-wave: "2"
Result: No more "CRD not found" or "provider not ready" errors! π
π Step 4: RBAC - The Critical Missing Piece
Crossplane needs permissions to manage your infrastructure. This is often overlooked:
apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: crossplane-provider-kubernetes-metallb rules: - apiGroups: ["metallb.io"] resources: ["ipaddresspools", "l2advertisements"] verbs: ["*"] --- # Bind to the provider's service account apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: crossplane-provider-kubernetes-metallb roleRef: kind: ClusterRole name: crossplane-provider-kubernetes-metallb subjects: - kind: ServiceAccount name: provider-kubernetes-xxxxx # π Get from kubectl namespace: crossplane-system
Pro tip: Restart provider pods after RBAC changes!
π§ͺ Testing the Complete Pipeline
Time for the moment of truth:
# 1. Bootstrap (one-time manual step) kubectl apply -f argocd/app-of-apps.yaml # 2. Check GitOps automation kubectl -n argocd get applications NAME SYNC STATUS HEALTH STATUS crossplane-provider-kubernetes Synced Healthy β
homelab-root Synced Healthy β
metallb-config Synced Healthy β
# 3. Verify infrastructure was created kubectl -n metallb-system get ipaddresspools.metallb.io NAME ADDRESSES homelab-pool ["10.20.0.81-10.20.0.99"] β
# 4. Test LoadBalancer functionality kubectl create deployment nginx --image=nginx kubectl expose deployment nginx --type=LoadBalancer --port=80 kubectl get svc nginx NAME TYPE EXTERNAL-IP PORT(S) nginx LoadBalancer 10.20.0.82 80:30114/TCP β
# 5. Verify connectivity curl http://10.20.0.82 <!DOCTYPE html> <html> <head><title>Welcome to nginx!</title></head> # π SUCCESS!
It works! Infrastructure deployed entirely through GitOps! π
π Repository Structure
homelab-gitops/ βββ argocd/ β βββ app-of-apps.yaml # π Root application βββ apps/ β βββ crossplane-provider-kubernetes-app.yaml β βββ metallb-config-app.yaml # πΆ Child applications βββ crossplane/ β βββ provider-kubernetes/ β βββ provider.yaml # β‘ Crossplane provider β βββ providerconfig.yaml # βοΈ Configuration β βββ rbac.yaml # π Permissions βββ metallb/ βββ metallb-ipaddresspool.yaml # π Infrastructure βββ metallb-l2advertisement.yaml # π¦ Resources
π‘ Key Lessons Learned
1. API Versions Are Critical
Different provider versions use different APIs:
# v0.13.0 uses v1alpha1 apiVersion: kubernetes.crossplane.io/v1alpha1 # Newer versions use v1alpha2 apiVersion: kubernetes.crossplane.io/v1alpha2
2. Bootstrap is Still Manual
Even with full GitOps, you need one manual step:
kubectl apply -f argocd/app-of-apps.yaml
After this, everything else is automated!
3. RBAC Debugging
If Crossplane objects stay "NotReady":
# Check provider permissions kubectl -n crossplane-system describe object metallb-ipaddresspool # Common fix: restart provider after RBAC changes kubectl -n crossplane-system delete pod -l pkg.crossplane.io/provider=provider-kubernetes
4. YAML Formatting Matters
Watch your indentation! This kept my root app OutOfSync:
# β Wrong destination: server: https://kubernetes.default.svc namespace: crossplane-system # β
Correct destination: server: https://kubernetes.default.svc namespace: crossplane-system
π What's Next?
This foundation scales to manage ANY infrastructure:
# Database clusters kind: PostgreSQLCluster # Service meshes kind: Istio # Monitoring stacks kind: PrometheusStack # Certificate management kind: ClusterIssuer # Storage solutions kind: StorageClass
The pattern stays the same:
- Define in YAML
- Commit to Git
- Argo CD syncs automatically
- Crossplane provisions infrastructure
- Profit! π°
π Results
Before:
- Manual
kubectl
commands - Configuration drift
- No audit trail
- Deployment anxiety π°
After:
- Infrastructure as Code
- Git-driven deployments
- Automatic drift correction
- Pull request reviews
- Confidence in production π
Infrastructure changes are now as simple as creating a pull request!
π Resources
- π Complete repository
- π Crossplane documentation
- π― Argo CD documentation
- ποΈ App-of-Apps pattern
What infrastructure will you GitOps next? Drop a comment and let me know what you're planning to automate! π
Follow me for more cloud-native and DevOps content! π
Top comments (1)
This is a textbook example of how GitOps can evolve from a deployment strategy into full-stack infrastructure orchestration. The use of Crossplane as a control plane abstraction paired with Argo CDβs sync waves is a brilliant way to tame dependency hell and enforce sequencing without brittle scripts.
The App-of-Apps pattern adds clarity and scalability, especially in environments where infra and app lifecycles diverge. And the RBAC integration with Crossplane? Thatβs the kind of operational hygiene that often gets overlooked until it breaks something critical.
Appreciate how youβve framed this not just as tooling, but as a mindset shift from manual ops to declarative, auditable infrastructure. Posts like this help teams move from βGitOps curiousβ to production-ready.