Crossplane Infrastructure Management on Kubernetes

Crossplane extends Kubernetes with the ability to provision and manage cloud infrastructure using the same GitOps workflows used for applications. This guide covers deploying Crossplane on Kubernetes, installing cloud providers, creating managed resources, and building compositions for self-service infrastructure claims.

Prerequisites

  • Kubernetes cluster 1.26+ (Minikube, kind, or production cluster)
  • kubectl configured with cluster-admin permissions
  • Helm 3.x
  • A cloud provider account (AWS, GCP, or Azure) for provisioning real resources

Install Crossplane

# Add the Crossplane Helm repository
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update

# Install Crossplane in its own namespace
helm install crossplane \
  crossplane-stable/crossplane \
  --namespace crossplane-system \
  --create-namespace \
  --set args='{"--debug"}' \
  --wait

# Verify the installation
kubectl get pods -n crossplane-system
kubectl get crds | grep crossplane

# Install the Crossplane CLI
curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh | sh
sudo mv crossplane /usr/local/bin/
crossplane --version

Install a Cloud Provider

Crossplane uses provider packages to manage specific clouds. Here we install the AWS provider:

# Install the AWS provider
cat <<EOF | kubectl apply -f -
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-s3
spec:
  package: xpkg.upbound.io/upbound/provider-aws-s3:v0.47.0
EOF

# Watch the provider install
kubectl get providers -w

# For a full AWS provider (all services)
cat <<EOF | kubectl apply -f -
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws
spec:
  package: xpkg.upbound.io/upbound/provider-family-aws:v0.47.0
  runtimeConfigRef:
    name: provider-aws-config
EOF

Configure Provider Credentials

# Create AWS credentials secret
AWS_PROFILE=default
cat > /tmp/aws-credentials.txt <<EOF
[default]
aws_access_key_id = $(aws configure get aws_access_key_id)
aws_secret_access_key = $(aws configure get aws_secret_access_key)
EOF

kubectl create secret generic aws-secret \
  -n crossplane-system \
  --from-file=creds=/tmp/aws-credentials.txt

# Clean up the temp file
rm /tmp/aws-credentials.txt

# Create a ProviderConfig referencing the secret
cat <<EOF | kubectl apply -f -
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: aws-secret
      key: creds
EOF

Create Managed Resources

Managed resources are direct mappings to cloud resources. Here's an S3 bucket:

# s3-bucket.yaml
apiVersion: s3.aws.upbound.io/v1beta1
kind: Bucket
metadata:
  name: my-crossplane-bucket
  annotations:
    crossplane.io/external-name: my-org-crossplane-demo-2024
spec:
  forProvider:
    region: us-east-1
    tags:
      Environment: production
      ManagedBy: crossplane
  providerConfigRef:
    name: default
kubectl apply -f s3-bucket.yaml

# Watch the resource sync
kubectl get bucket my-crossplane-bucket -w

# Check sync status
kubectl describe bucket my-crossplane-bucket

Create an RDS instance:

# rds-instance.yaml
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
metadata:
  name: my-postgres-db
spec:
  forProvider:
    region: us-east-1
    instanceClass: db.t3.micro
    engine: postgres
    engineVersion: "15.3"
    dbName: appdb
    username: dbadmin
    passwordSecretRef:
      name: rds-password
      namespace: default
      key: password
    allocatedStorage: 20
    skipFinalSnapshot: true
  providerConfigRef:
    name: default
# Create the password secret first
kubectl create secret generic rds-password \
  --from-literal=password='SuperSecurePass123!'

kubectl apply -f rds-instance.yaml
kubectl get instance my-postgres-db -w

Build Compositions and Composite Resources

Compositions combine multiple managed resources into reusable abstractions:

# composite-resource-definition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xpostgresinstances.platform.example.com
spec:
  group: platform.example.com
  names:
    kind: XPostgresInstance
    plural: xpostgresinstances
  claimNames:
    kind: PostgresInstance
    plural: postgresinstances
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                parameters:
                  type: object
                  properties:
                    storageGB:
                      type: integer
                      default: 20
                    region:
                      type: string
                      default: us-east-1
                  required:
                    - storageGB
# composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: postgres-aws
  labels:
    provider: aws
    db: postgres
spec:
  compositeTypeRef:
    apiVersion: platform.example.com/v1alpha1
    kind: XPostgresInstance
  resources:
    - name: rds-instance
      base:
        apiVersion: rds.aws.upbound.io/v1beta1
        kind: Instance
        spec:
          forProvider:
            region: us-east-1
            instanceClass: db.t3.micro
            engine: postgres
            engineVersion: "15.3"
            dbName: appdb
            username: dbadmin
            skipFinalSnapshot: true
          providerConfigRef:
            name: default
      patches:
        - type: FromCompositeFieldPath
          fromFieldPath: spec.parameters.storageGB
          toFieldPath: spec.forProvider.allocatedStorage
        - type: FromCompositeFieldPath
          fromFieldPath: spec.parameters.region
          toFieldPath: spec.forProvider.region
kubectl apply -f composite-resource-definition.yaml
kubectl apply -f composition.yaml

Self-Service Infrastructure Claims

Namespace-scoped claims let developers request infrastructure without cluster-admin access:

# postgres-claim.yaml - deployed by application team
apiVersion: platform.example.com/v1alpha1
kind: PostgresInstance
metadata:
  name: my-app-database
  namespace: team-alpha
spec:
  parameters:
    storageGB: 50
    region: us-east-1
  compositionSelector:
    matchLabels:
      provider: aws
      db: postgres
  writeConnectionSecretToRef:
    name: my-app-db-credentials
kubectl apply -f postgres-claim.yaml

# The claim creates a composite resource, which creates the RDS instance
kubectl get postgresinstance -n team-alpha
kubectl get xpostgresinstance

# Connection details written to a secret once ready
kubectl get secret my-app-db-credentials -n team-alpha -o yaml

GitOps Integration

Store all Crossplane resources in Git and apply them via Argo CD or Flux:

# argo-cd application for infrastructure
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: platform-infrastructure
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/my-org/infrastructure
    targetRevision: main
    path: crossplane/
  destination:
    server: https://kubernetes.default.svc
    namespace: crossplane-system
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
# Directory structure for GitOps
# infrastructure/
# ├── crossplane/
# │   ├── providers/
# │   │   └── aws-provider.yaml
# │   ├── compositions/
# │   │   └── postgres-composition.yaml
# │   └── xrds/
# │       └── postgres-xrd.yaml
# └── claims/
#     └── team-alpha/
#         └── postgres-claim.yaml

Troubleshooting

Provider stuck in "Installing" state:

kubectl describe provider provider-aws
kubectl get pods -n crossplane-system
# Check if the provider pod has image pull errors:
kubectl get events -n crossplane-system --sort-by='.lastTimestamp'

Managed resource not syncing:

# Check conditions on the resource
kubectl describe bucket my-crossplane-bucket | grep -A 20 "Conditions:"

# View Crossplane controller logs
kubectl logs -n crossplane-system -l app=crossplane --tail=100

# Check if the resource is ready
kubectl get managed

ProviderConfig credential errors:

# Verify the secret exists and has correct keys
kubectl get secret aws-secret -n crossplane-system -o jsonpath='{.data.creds}' | base64 -d

# Describe the ProviderConfig
kubectl describe providerconfig default

Composition patches not applying:

# Validate your composition
crossplane beta validate composition.yaml

# Check composite resource status
kubectl describe xpostgresinstance

Conclusion

Crossplane brings infrastructure provisioning into Kubernetes-native GitOps workflows, enabling platform teams to expose self-service abstractions via compositions and claims. Developers can request databases, buckets, and networking resources through simple Kubernetes objects without needing direct cloud access. Combined with Argo CD or Flux, Crossplane delivers true GitOps-driven infrastructure management that scales with your organization.