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.