DevOpsil

Crossplane: Managing Cloud Infrastructure from Kubernetes

Zara BlackwoodZara Blackwood8 min read

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

CriteriaCrossplaneTerraform
Self-service for dev teamsStrongWeak (requires IaC skills)
Continuous drift correctionBuilt-inRequires scheduled plans
Foundational infra (VPC, EKS)Possible but complexPreferred
Community module ecosystemGrowingMassive
Multi-cloud abstractionsNative with XRDsModule wrappers
State managementKubernetes etcdS3 + DynamoDB
GitOps integrationNative (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.

Share:
Zara Blackwood
Zara Blackwood

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