Crossplane: Managing Cloud Infrastructure from Kubernetes
One Control Plane to Rule Them All
What if developers could provision an RDS database the same way they create a Deployment? No Terraform, no cloud console — just kubectl apply and the infrastructure appears.
That's Crossplane. It extends Kubernetes with custom resources that map to cloud infrastructure. Your cluster becomes the control plane for everything — containers, databases, queues, DNS records, and buckets.
I've run Crossplane in production for 18 months. Here's what works and how to set it up.
Installing Crossplane
# crossplane-system/helmrelease.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: crossplane
namespace: crossplane-system
spec:
interval: 1h
chart:
spec:
chart: crossplane
version: "1.15.x"
sourceRef:
kind: HelmRepository
name: crossplane-stable
values:
replicas: 2
resourcesCrossplane:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 256Mi
metrics:
enabled: true
Deploy with GitOps. If it's not in a Git repo, it doesn't exist.
Configuring the AWS Provider
# crossplane-system/provider-aws.yaml
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-s3
spec:
package: xpkg.upbound.io/upbound/provider-aws-s3:v1.5.0
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-rds
spec:
package: xpkg.upbound.io/upbound/provider-aws-rds:v1.5.0
Use family providers, not the monolithic provider-aws. The monolith installs 1,200+ CRDs and will crush your API server. Family providers install only what you need.
# crossplane-system/provider-config.yaml
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: default
spec:
credentials:
source: IRSA
assumeRoleChain:
- roleARN: arn:aws:iam::123456789012:role/crossplane-provider-aws
Never use static credentials. IRSA is the only acceptable auth method in EKS.
Composite Resource Definition
Raw managed resources expose every cloud API field — too much power for app teams. Build opinionated abstractions with XRDs.
# apis/database/definition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xdatabases.platform.devopsil.com
spec:
group: platform.devopsil.com
names:
kind: XDatabase
plural: xdatabases
claimNames:
kind: Database
plural: databases
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
engine:
type: string
enum: ["postgres", "mysql"]
size:
type: string
enum: ["small", "medium", "large"]
region:
type: string
default: "us-east-1"
required: [engine, size]
T-shirt sizing is the key abstraction. Developers pick "small" or "large" — not db.r6g.xlarge. The platform team maps sizes to instance types behind the scenes.
The Composition
# apis/database/composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xdatabases.aws.platform.devopsil.com
spec:
compositeTypeRef:
apiVersion: platform.devopsil.com/v1alpha1
kind: XDatabase
mode: Pipeline
pipeline:
- step: patch-and-transform
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: rds-instance
base:
apiVersion: rds.aws.upbound.io/v1beta2
kind: Instance
spec:
forProvider:
region: us-east-1
allocatedStorage: 20
storageEncrypted: true
storageType: gp3
publiclyAccessible: false
backupRetentionPeriod: 7
deletionProtection: true
tags:
ManagedBy: crossplane
writeConnectionSecretToRef:
namespace: crossplane-system
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.engine
toFieldPath: spec.forProvider.engine
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- type: FromCompositeFieldPath
fromFieldPath: spec.size
toFieldPath: spec.forProvider.instanceClass
transforms:
- type: map
map:
small: db.t4g.micro
medium: db.r6g.large
large: db.r6g.xlarge
Security defaults baked in: encryption on, public access off, backups enabled. Developers can't skip them because they never see the fields.
Developer Experience
# app-team/database.yaml
apiVersion: platform.devopsil.com/v1alpha1
kind: Database
metadata:
name: orders-db
namespace: orders-team
spec:
engine: postgres
size: medium
writeConnectionSecretToRef:
name: orders-db-creds
Five fields. That's all a developer needs for a production-grade PostgreSQL database. Credentials land in a Secret they mount directly:
# app-team/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
spec:
template:
spec:
containers:
- name: api
image: orders-api:v1.4.0
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: orders-db-creds
key: endpoint
Monitoring
# monitoring/crossplane-alerts.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: crossplane-alerts
spec:
groups:
- name: crossplane.rules
rules:
- alert: CrossplaneManagedResourceNotReady
expr: crossplane_managed_resource_ready{ready="False"} == 1
for: 15m
labels:
severity: warning
- alert: CrossplaneProviderUnhealthy
expr: crossplane_provider_revision_healthy{healthy="False"} == 1
for: 5m
labels:
severity: critical
Troubleshooting
Problem: Resource stuck in "Creating" state.
Fix: Check provider pod logs: kubectl logs -n crossplane-system -l pkg.crossplane.io/revision. Usually an IAM permission issue.
Problem: CRDs overwhelming the API server. Fix: Switch from monolithic to family providers. Only install what you use.
Problem: Composition patches not applying.
Fix: Run crossplane beta trace <kind> <name> to see the full resource tree.
Composition Functions for Complex Logic
Basic patching handles most cases. When you need conditional logic, computed values, or cross-resource references, use composition functions.
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xdatabases.aws.platform.devopsil.com
spec:
compositeTypeRef:
apiVersion: platform.devopsil.com/v1alpha1
kind: XDatabase
mode: Pipeline
pipeline:
- step: patch-and-transform
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: subnet-group
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: SubnetGroup
spec:
forProvider:
region: us-east-1
description: "Managed by Crossplane"
subnetIds:
- subnet-abc123
- subnet-def456
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.region
toFieldPath: spec.forProvider.region
- name: rds-instance
base:
apiVersion: rds.aws.upbound.io/v1beta2
kind: Instance
spec:
forProvider:
dbSubnetGroupNameSelector:
matchControllerRef: true
storageEncrypted: true
publiclyAccessible: false
deletionProtection: true
patches:
- type: FromCompositeFieldPath
fromFieldPath: spec.engine
toFieldPath: spec.forProvider.engine
- type: FromCompositeFieldPath
fromFieldPath: spec.size
toFieldPath: spec.forProvider.instanceClass
transforms:
- type: map
map:
small: db.t4g.micro
medium: db.r6g.large
large: db.r6g.xlarge
# Set allocated storage based on size
- type: FromCompositeFieldPath
fromFieldPath: spec.size
toFieldPath: spec.forProvider.allocatedStorage
transforms:
- type: map
map:
small: 20
medium: 100
large: 500
- step: auto-ready
functionRef:
name: function-auto-detect-ready
The function-auto-detect-ready step automatically marks the composite resource as ready when all managed resources are ready. Without it, you need manual readiness checks.
Multi-Cloud Compositions
One XRD can have multiple compositions — one per cloud provider. The developer writes the same claim, and the platform routes it to the right cloud.
# AWS composition
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xdatabases.aws.platform.devopsil.com
labels:
provider: aws
spec:
compositeTypeRef:
apiVersion: platform.devopsil.com/v1alpha1
kind: XDatabase
# ... AWS-specific resources
---
# GCP composition
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xdatabases.gcp.platform.devopsil.com
labels:
provider: gcp
spec:
compositeTypeRef:
apiVersion: platform.devopsil.com/v1alpha1
kind: XDatabase
# ... GCP-specific resources (CloudSQL)
Select the composition at claim time:
apiVersion: platform.devopsil.com/v1alpha1
kind: Database
metadata:
name: orders-db
spec:
compositionSelector:
matchLabels:
provider: aws
engine: postgres
size: medium
Same developer experience. Different clouds. The platform team manages the complexity behind compositions.
Drift Detection and Continuous Reconciliation
This is Crossplane's killer feature over Terraform. Crossplane continuously reconciles — if someone changes a resource in the AWS console, Crossplane reverts it.
# Watch Crossplane reconcile in real time
kubectl get managed -w
# Check a specific resource's sync status
kubectl describe instance.rds.aws.upbound.io orders-db-rds
The reconciliation loop runs every 60 seconds by default. If someone disables encryption on your RDS instance through the console, Crossplane re-enables it on the next reconciliation. Configuration drift becomes impossible for Crossplane-managed resources.
To see what Crossplane manages across your cluster:
# List all managed resources and their status
kubectl get managed --all-namespaces -o custom-columns=\
KIND:.kind,\
NAME:.metadata.name,\
READY:.status.conditions[?(@.type=='Ready')].status,\
SYNCED:.status.conditions[?(@.type=='Synced')].status
When to Use Crossplane vs. Terraform
| Criteria | Crossplane | Terraform |
|---|---|---|
| Self-service for dev teams | Strong | Weak (requires IaC skills) |
| Continuous drift correction | Built-in | Requires scheduled plans |
| Foundational infra (VPC, EKS) | Possible but complex | Preferred |
| Community module ecosystem | Growing | Massive |
| Multi-cloud abstractions | Native with XRDs | Module wrappers |
| State management | Kubernetes etcd | S3 + DynamoDB |
| GitOps integration | Native (kubectl apply) | Requires wrapper (Atlantis) |
Use Crossplane for self-service infrastructure, continuous reconciliation, and when Kubernetes is already your control plane. Use Terraform for foundational infrastructure (the EKS cluster itself) and resources Crossplane doesn't support yet. They're not mutually exclusive — I use both.
Testing Compositions
Compositions are code. Test them before deploying to production.
# Render a composition locally to verify the output
crossplane beta render \
claim.yaml \
composition.yaml \
definition.yaml
# Validate the rendered resources
crossplane beta render claim.yaml composition.yaml definition.yaml | \
kubectl apply --dry-run=server -f -
For CI integration:
# .github/workflows/crossplane-test.yml
name: Test Crossplane Compositions
on:
pull_request:
paths: ['apis/**']
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: crossplane-contrib/setup-crossplane-cli@v1
- run: |
for dir in apis/*/; do
echo "Testing $dir"
crossplane beta render \
"$dir/test/claim.yaml" \
"$dir/composition.yaml" \
"$dir/definition.yaml" > /dev/null
echo "OK: $dir"
done
Common Pitfalls
Pitfall 1: Starting with too many resource types. Pick one — databases or buckets — and build a solid XRD with good defaults. Expand once your team trusts the platform.
Pitfall 2: Exposing too many fields in XRDs. The whole point of an abstraction is hiding complexity. If your XRD has 30 fields, you've just reinvented the AWS API with extra steps.
Pitfall 3: Not setting deletionProtection. Crossplane honors kubectl delete. Without deletion protection on the composition, a developer running kubectl delete database orders-db drops your production database.
Pitfall 4: Ignoring provider pod resource limits. AWS provider pods can consume 500MB+ of memory. Without resource limits, they evict other pods on the node.
Conclusion
Crossplane turns Kubernetes into a universal infrastructure control plane. The real win is developer experience: five lines of YAML and a kubectl apply for a production database. Build opinionated abstractions with XRDs, enforce security defaults in compositions, and let continuous reconciliation prevent drift. Start with one resource type, nail the abstraction, then expand.
Related Articles
Platform Engineer
Terraform enthusiast, platform builder, DRY advocate. I believe infrastructure should be versioned, reviewed, and deployed like any other code. GitOps or bust.
Related Articles
ArgoCD Application Patterns: App of Apps, ApplicationSets, and Beyond
Practical ArgoCD patterns for managing dozens of applications — from App of Apps to ApplicationSets to multi-cluster rollouts. All in code, obviously.
Pulumi vs Terraform: An Honest Comparison from the Trenches
A real-world comparison of Pulumi and Terraform — where each shines, where each hurts, and how to pick the right one for your team.
Terraform from Zero to Production: Project Structure, Modules, State, and CI/CD
Build production-grade Terraform infrastructure — project structure, module design, state management, testing, and CI/CD pipeline integration.