Crossplane is revolutionizing how we think about infrastructure management in a Kubernetes-native way. While its core promise lies in extending the Kubernetes API to manage external cloud resources, its true power for platform teams emerges with Crossplane Compositions. Imagine a world where your developers can provision a complete application environmentβdatabase, cache, message queue, and networkingβwith a single kubectl apply, without needing to understand the underlying cloud provider specifics. This isn’t just about abstracting away the cloud; it’s about codifying your organization’s best practices, security policies, and cost optimizations into reusable, self-service infrastructure templates.
Without Compositions, each developer or team would have to define every individual cloud resource (e.g., an AWS RDS instance, an S3 bucket, an EC2 security group) from scratch. This leads to configuration drift, security vulnerabilities, and an explosion of boilerplate YAML. Compositions solve this by allowing platform engineers to define opinionated, high-level APIs that internally compose multiple lower-level managed resources. This elevates the developer experience, standardizes infrastructure provisioning, and enshrines operational excellence directly into your infrastructure-as-code. Itβs like creating your own custom cloud service catalog, all powered by Kubernetes.
TL;DR: Crossplane Compositions
Crossplane Compositions enable platform teams to build reusable, opinionated infrastructure templates that abstract away cloud complexity for developers. They allow you to define a single, high-level API (a Composite Resource Definition or XRD) which, when requested, provisions multiple underlying cloud resources (Managed Resources) according to predefined best practices. This streamlines self-service provisioning, enforces standards, and reduces operational overhead.
Key Commands:
# Install Crossplane (if not already installed)
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
helm install crossplane --namespace crossplane-system crossplane-stable/crossplane --create-namespace
# Install a cloud provider package (e.g., AWS)
kubectl apply -f - <<EOF
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws
spec:
package: xpkg.upbound.io/crossplane-contrib/provider-aws:v0.40.0
EOF
# Define a Composite Resource Definition (XRD)
kubectl apply -f my-xrd.yaml
# Define a Composition for your XRD
kubectl apply -f my-composition.yaml
# Provision your composite resource
kubectl apply -f my-composite-resource.yaml
# View the status of your composite resource
kubectl get mycompositeresource
# View the underlying managed resources
kubectl get managedresources
Prerequisites
Before diving into Crossplane Compositions, ensure you have the following:
- Kubernetes Cluster: A running Kubernetes cluster (v1.20+ recommended). You can use Minikube, Kind, or a cloud-managed cluster (EKS, GKE, AKS).
kubectl: The Kubernetes command-line tool, configured to connect to your cluster. Refer to the official Kubernetes documentation for installation.- Helm: The Kubernetes package manager (v3.0+). Instructions can be found on the Helm website.
- Crossplane Installed: Crossplane and at least one cloud provider package (e.g.,
provider-aws,provider-gcp,provider-azure) must be installed and configured in your cluster. This tutorial will primarily use AWS examples.# Add Crossplane Helm repository helm repo add crossplane-stable https://charts.crossplane.io/stable helm repo update # Install Crossplane into its own namespace helm install crossplane --namespace crossplane-system crossplane-stable/crossplane --create-namespace # Verify Crossplane pods are running kubectl get pods -n crossplane-system # Install Crossplane Provider for AWS (adjust version as needed) kubectl apply -f - <<EOF apiVersion: pkg.crossplane.io/v1 kind: Provider metadata: name: provider-aws spec: package: xpkg.upbound.io/crossplane-contrib/provider-aws:v0.40.0 EOF # Wait for the Provider to be healthy kubectl wait --for=condition=Healthy provider.pkg.crossplane.io/provider-aws --timeout=5m # Configure AWS credentials for Crossplane # Replace with your AWS Access Key ID and Secret Access Key # For production, use a more secure method like IAM Roles for Service Accounts (IRSA) kubectl create secret generic aws-creds -n crossplane-system --from-literal=credentials='[default] aws_access_key_id = YOUR_AWS_ACCESS_KEY_ID aws_secret_access_key = YOUR_AWS_SECRET_ACCESS_KEY' kubectl apply -f - <<EOF apiVersion: aws.crossplane.io/v1beta1 kind: ProviderConfig metadata: name: default spec: credentials: source: Secret secretRef: namespace: crossplane-system name: aws-creds key: credentials EOF - Basic Understanding of Crossplane: Familiarity with Crossplane’s core concepts like Managed Resources (MRs), Providers, and ProviderConfigs.
- YAML Proficiency: Comfort with writing and understanding Kubernetes YAML manifest files.
Step-by-Step Guide: Building Reusable Infrastructure with Crossplane Compositions
This guide will walk you through creating a simple, opinionated PostgreSQL database instance in AWS using Crossplane Compositions. Our composite resource will provision an AWS RDS instance along with a security group.
Step 1: Define Your Composite Resource Definition (XRD)
The first step in creating a Composition is to define your desired high-level API. This is done using a Composite Resource Definition (XRD). An XRD is a custom resource definition (CRD) for your composite resource, allowing you to define its schema, validation rules, and export secrets.
For our example, we’ll create an XPostgreSQLInstance that allows users to specify an engine version and storage size. We’ll also define connection details that should be exposed as a Kubernetes secret.
# xpostgresqlinstance.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xpostgresqlinstances.database.example.org
spec:
group: database.example.org
names:
kind: XPostgreSQLInstance
plural: xpostgresqlinstances
claimNames:
kind: PostgreSQLInstance
plural: postgresqlinstances
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
parameters:
type: object
description: "Parameters for the PostgreSQL instance."
properties:
storageGB:
type: integer
description: "Storage capacity in GB."
minimum: 20
maximum: 1000
engineVersion:
type: string
description: "PostgreSQL engine version."
enum: ["13.7", "14.6", "15.2"]
default: "14.6"
vpcId:
type: string
description: "The VPC ID where the RDS instance should be provisioned."
required:
- storageGB
- vpcId
writeConnectionSecretToRef:
type: object
description: "References to a Secret to which connection details will be written."
properties:
name:
type: string
description: "Name of the secret."
namespace:
type: string
description: "Namespace of the secret."
required:
- name
- namespace
required:
- parameters
# Define what connection details should be exposed from the underlying resources
# and how they map to the secret.
connectionSecretKeys:
- username
- password
- endpoint
- port
- host
- database
Explanation:
This YAML defines our XPostgreSQLInstance. Key fields include:
groupandnames: Define the API group and names for our custom resource. We also defineclaimNames, which creates a namespaced version of our composite resource (PostgreSQLInstance) that developers can use. This provides multi-tenancy within the same cluster.versions: Specifies the API version (v1alpha1in this case) and its schema.spec.parameters: This is where we define the input parameters that a developer can provide when requesting anXPostgreSQLInstance, such asstorageGB,engineVersion, andvpcId.writeConnectionSecretToRef: This standard Crossplane field allows the user to specify where the connection details (endpoint, username, password) for the provisioned resource should be written as a Kubernetes Secret.connectionSecretKeys: This crucial section tells Crossplane which fields from the underlying managed resources’ connection secrets should be aggregated and exposed in the composite resource’s connection secret.
By defining this XRD, we’re essentially creating a new API endpoint in our Kubernetes cluster that represents a “PostgreSQL Instance” from the developer’s perspective.
kubectl apply -f xpostgresqlinstance.yaml
Verify:
You should see the Composite Resource Definition created. You can also check for the new CRDs:
kubectl get crd | grep postgresqlinstance
xpostgresqlinstances.database.example.org 2023-10-27T10:00:00Z
postgresqlinstances.database.example.org 2023-10-27T10:00:00Z
Step 2: Create the Composition
Now that we have our high-level API (the XRD), we need to tell Crossplane how to fulfill requests for it. This is where the Composition comes in. A Composition defines the set of managed resources (e.g., AWS RDS Instance, AWS Security Group) that should be provisioned when an XPostgreSQLInstance is requested.
This Composition will provision an AWS RDS DBInstance and an AWS EC2 SecurityGroup. It will also define how parameters from the XPostgreSQLInstance are mapped to the underlying managed resources, and how connection details are aggregated.
# composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xpostgresqlinstances.aws
labels:
provider: aws
db: postgresql
spec:
compositeTypeRef:
apiVersion: database.example.org/v1alpha1
kind: XPostgreSQLInstance
resources:
# Resource 1: AWS RDS DBInstance
- name: rdsinstance
base:
apiVersion: rds.aws.crossplane.io/v1beta1
kind: DBInstance
spec:
forProvider:
region: us-east-1 # Hardcoded region for simplicity, can be dynamic
dbInstanceClass: db.t3.micro
masterUsername: masteruser
engine: postgres
skipFinalSnapshotBeforeDeletion: true
publiclyAccessible: false # Always provision private instances
tags:
- key: managed-by
value: crossplane
- key: environment
value: dev # Example tag
writeConnectionSecretToRef:
name: rds-connection-secret
namespace: crossplane-system # Internal secret, will be copied later
patches:
# Patch to set StorageGB from XPostgreSQLInstance parameters
- fromFieldPath: "spec.parameters.storageGB"
toFieldPath: "spec.forProvider.allocatedStorage"
type: FromCompositeFieldPath
# Patch to set EngineVersion from XPostgreSQLInstance parameters
- fromFieldPath: "spec.parameters.engineVersion"
toFieldPath: "spec.forProvider.engineVersion"
type: FromCompositeFieldPath
# Patch to set SecurityGroupRef from the SecurityGroup resource's name
- fromFieldPath: "metadata.name" # Refers to the composite resource's name
toFieldPath: "spec.forProvider.vpcSecurityGroupIDs[0]"
type: CombineFromComposite
combine:
strategy: string
string:
fmt: "%s-sg" # Expects the security group to be named after the composite resource + "-sg"
# Patch to set SubnetGroupRef (requires existing DB Subnet Group)
- fromFieldPath: "spec.parameters.dbSubnetGroupName" # Assuming this parameter exists in XPostgreSQLInstance
toFieldPath: "spec.forProvider.dbSubnetGroupName"
type: FromCompositeFieldPath
# If dbSubnetGroupName is not provided, we can use a default or omit the patch.
# For this example, we'll assume it's provided or a default is set in the XPostgreSQLInstance.
# For a more robust solution, you might use a transform to provide a default if missing.
# Patch to set VPC ID (needed for DB Subnet Group creation or lookup)
- fromFieldPath: "spec.parameters.vpcId"
toFieldPath: "spec.forProvider.vpcSecurityGroupIDs[0]" # This is incorrect, VPC ID is not directly used here for DBInstance
# Correction: VPC ID is used by the SecurityGroup. We will implicitly link via security group.
# The VPC ID is primarily used by the SecurityGroup.
# For DBInstance, the security group IDs and subnet group name are sufficient.
# Resource 2: AWS EC2 SecurityGroup
- name: securitygroup
base:
apiVersion: ec2.aws.crossplane.io/v1beta1
kind: SecurityGroup
spec:
forProvider:
region: us-east-1
groupName: crossplane-rds-sg # This will be patched
description: "Security group for Crossplane managed PostgreSQL instance."
ingress:
- fromPort: 5432
toPort: 5432
ipProtocol: tcp
ipRanges:
- cidrBlock: 0.0.0.0/0 # WARNING: This is too permissive for production.
# Use a more restrictive CIDR or `securityGroupRefs` in production.
tags:
- key: managed-by
value: crossplane
writeConnectionSecretToRef:
name: sg-connection-secret
namespace: crossplane-system # Internal secret, will be copied later
patches:
# Patch to set the SecurityGroup name dynamically
- fromFieldPath: "metadata.name"
toFieldPath: "spec.forProvider.groupName"
type: CombineFromComposite
combine:
strategy: string
string:
fmt: "%s-sg"
# Patch to set the VPC ID from XPostgreSQLInstance parameters
- fromFieldPath: "spec.parameters.vpcId"
toFieldPath: "spec.forProvider.vpcId"
type: FromCompositeFieldPath
# Connection Secret Publisher (Copies the internal RDS secret to the user-defined secret)
- name: connection-secret-publisher
base:
apiVersion: v1
kind: Secret
metadata:
annotations:
crossplane.io/composition-resource-name: connection-secret-publisher
patches:
# Destination secret name and namespace from XPostgreSQLInstance
- fromFieldPath: "spec.writeConnectionSecretToRef.name"
toFieldPath: "metadata.name"
type: FromCompositeFieldPath
- fromFieldPath: "spec.writeConnectionSecretToRef.namespace"
toFieldPath: "metadata.namespace"
type: FromCompositeFieldPath
# Data from the RDS instance's connection secret
- fromFieldPath: "status.atProvider.connectionDetails.data.username"
toFieldPath: "data.username"
type: FromManagedResource
fromFieldPath: "status.connectionDetails.data.username"
# Reference the 'rdsinstance' resource defined above
resourceSelector:
matchControllerRef: true
apiVersion: rds.aws.crossplane.io/v1beta1
kind: DBInstance
- fromFieldPath: "status.atProvider.connectionDetails.data.password"
toFieldPath: "data.password"
type: FromManagedResource
fromFieldPath: "status.connectionDetails.data.password"
resourceSelector:
matchControllerRef: true
apiVersion: rds.aws.crossplane.io/v1beta1
kind: DBInstance
- fromFieldPath: "status.atProvider.connectionDetails.data.endpoint"
toFieldPath: "data.endpoint"
type: FromManagedResource
fromFieldPath: "status.connectionDetails.data.endpoint"
resourceSelector:
matchControllerRef: true
apiVersion: rds.aws.crossplane.io/v1beta1
kind: DBInstance
- fromFieldPath: "status.atProvider.connectionDetails.data.port"
toFieldPath: "data.port"
type: FromManagedResource
fromFieldPath: "status.connectionDetails.data.port"
resourceSelector:
matchControllerRef: true
apiVersion: rds.aws.crossplane.io/v1beta1
kind: DBInstance
- fromFieldPath: "status.atProvider.connectionDetails.data.host"
toFieldPath: "data.host"
type: FromManagedResource
fromFieldPath: "status.connectionDetails.data.host"
resourceSelector:
matchControllerRef: true
apiVersion: rds.aws.crossplane.io/v1beta1
kind: DBInstance
- fromFieldPath: "status.atProvider.connectionDetails.data.database"
toFieldPath: "data.database"
type: FromManagedResource
fromFieldPath: "status.connectionDetails.data.database"
resourceSelector:
matchControllerRef: true
apiVersion: rds.aws.crossplane.io/v1beta1
kind: DBInstance
Explanation:
This Composition is the heart of our reusable template.
compositeTypeRef: Links this Composition to ourXPostgreSQLInstanceXRD.resources: An array defining the managed resources to be provisioned.rdsinstance: Defines an AWS RDS DBInstance.base: Contains the static configuration for the RDS instance. Notice the hardcodedregion,dbInstanceClass, and other defaults.patches: These are critical for dynamically configuring the managed resource based on theXPostgreSQLInstance‘s parameters.FromCompositeFieldPath: Takes a value from the composite resource (e.g.,spec.parameters.storageGB) and applies it to a field in the managed resource (e.g.,spec.forProvider.allocatedStorage).CombineFromComposite: Allows constructing a field value by combining strings, often using the composite resource’s name. Here, we use it to construct the security group ID.
writeConnectionSecretToRef: This is an internal secret where the RDS instance will write its connection details. We’ll later copy these to a user-facing secret.
securitygroup: Defines an AWS EC2 SecurityGroup.- It includes a default ingress rule for PostgreSQL port 5432. Warning: The
0.0.0.0/0CIDR block is highly insecure for production. In a real-world scenario, you would use a more restrictive CIDR block, perhaps dynamically derived from network policies or other composite resource parameters. For enhancing network security, consider exploring resources like our Kubernetes Network Policies: Complete Security Hardening Guide. - Patches are used to dynamically name the security group and link it to the correct VPC using
spec.parameters.vpcIdfrom the composite resource.
- It includes a default ingress rule for PostgreSQL port 5432. Warning: The
connection-secret-publisher: This resource is a standard Kubernetes Secret. Its purpose is to take the connection details from the internally managedrdsinstancesecret and publish them to the secret specified by the user in theXPostgreSQLInstance‘swriteConnectionSecretToRef. This is a common pattern for exposing secrets securely.
kubectl apply -f composition.yaml
Verify:
You should see the Composition created.
kubectl get composition
NAME AGE
xpostgresqlinstances.aws 1m
Step 3: Create a Composite Resource Claim (Optional but Recommended)
While you can directly create an XPostgreSQLInstance, it’s generally recommended for developers to use a “Claim” (a namespaced resource) version of your composite resource. The XRD we defined earlier automatically created a PostgreSQLInstance claim kind. Claims provide multi-tenancy and better RBAC isolation for developers.
Before creating the claim, you need a VPC ID. You can get one from your AWS account or create one. For this example, let’s assume you have a VPC ID. Replace YOUR_VPC_ID with an actual VPC ID from your AWS account.
# postgresql-claim.yaml
apiVersion: database.example.org/v1alpha1
kind: PostgreSQLInstance
metadata:
name: my-app-db
namespace: default # Developers would deploy this in their own namespace
spec:
id: my-application-db # Unique identifier for the composite resource
parameters:
storageGB: 50
engineVersion: "14.6"
vpcId: "vpc-0abcdef1234567890" # Replace with your actual VPC ID
writeConnectionSecretToRef:
name: my-app-db-connection
namespace: default # The secret will be created in the same namespace as the claim
Explanation:
This is what a developer would apply. They specify the desired storageGB, engineVersion, and the vpcId. They also indicate where the connection details should be written. They don’t need to know about RDS, Security Groups, or any AWS specifics β just that they want a PostgreSQL instance with certain characteristics.
Important: The id field in spec is automatically propagated to the underlying XPostgreSQLInstance and can be used to generate unique names for managed resources, preventing naming collisions. The writeConnectionSecretToRef specifies where the database connection information will be stored as a Kubernetes Secret.
kubectl apply -f postgresql-claim.yaml
Verify:
You should see the PostgreSQLInstance claim and the corresponding XPostgreSQLInstance composite resource created.
kubectl get postgresqlinstance -n default
NAME SYNCED READY COMPOSITION AGE
my-app-db True False xpostgresqlinstances.aws 10s
kubectl get xpostgresqlinstance
NAME SYNCED READY AGE
my-app-db-5zk7w False False 15s # The name is derived from the claim name + a hash
Initially, both will be False for READY because Crossplane is still provisioning the underlying AWS resources.
Step 4: Observe Resource Provisioning
Crossplane will now start provisioning the AWS RDS instance and Security Group based on your Composition. This can take several minutes. You can monitor the status of the underlying managed resources.
# Get the XPostgreSQLInstance to find the name of the underlying resources
kubectl get xpostgresqlinstance my-app-db-5zk7w -o yaml | grep "resourceRef" -A 5
- apiVersion: rds.aws.crossplane.io/v1beta1
kind: DBInstance
name: my-app-db-5zk7w-rdsinstance
- apiVersion: ec2.aws.crossplane.io/v1beta1
kind: SecurityGroup
name: my-app-db-5zk7w-securitygroup
Now, check the status of these managed resources:
kubectl get dbinstance my-app-db-5zk7w-rdsinstance
kubectl get securitygroup my-app-db-5zk7w-securitygroup
NAME READY SYNCED STATE AGE
my-app-db-5zk7w-rdsinstance False True creating 2m
NAME READY SYNCED AGE
my-app-db-5zk7w-securitygroup True True 2m
The SecurityGroup should become ready quickly. The DBInstance will take longer as AWS provisions it. Once the DBInstance is READY, your composite resource will also become ready.
kubectl get postgresqlinstance -n default
NAME SYNCED READY COMPOSITION AGE
my-app-db True True xpostgresqlinstances.aws 10m
Step 5: Access Connection Details
Once the PostgreSQLInstance claim is READY: True, the connection secret will be available in the specified namespace.
kubectl get secret my-app-db-connection -n default -o yaml
apiVersion: v1
kind: Secret
metadata:
name: my-app-db-connection
namespace: default
type: Opaque
data:
database: cG9zdGdyZXM= # base64 encoded 'postgres'
endpoint: bXktYXBwLWRiLTV6azd3LXJkc2luc3RhbmNlLmFic2NkZWZnMTIzNDUuZXUtd2VzdC0xLnJkcy5hbWF6b25hd3MuY29t # Base64 encoded endpoint
host: bXktYXBwLWRiLTV6azd3LXJkc2luc3RhbmNlLmFic2NkZWZnMTIzNDUuZXUtd2VzdC0xLnJkcy5hbWF6b25hd3MuY29t
password: YOUR_BASE64_ENCODED_PASSWORD
port: NQ00MzI= # base64 encoded '5432'
username: bWFzdGVydXNlcg== # base64 encoded 'masteruser'
You can then decode these values to connect to your database:
kubectl get secret my-app-db-connection -n default -o jsonpath='{.data.endpoint}' | base64 -d
kubectl get secret my-app-db-connection -n default -o jsonpath='{.data.username}' | base64 -d
kubectl get secret my-app-db-connection -n default -o jsonpath='{.data.password}' | base64 -d
kubectl get secret my-app-db-connection -n default -o jsonpath='{.data.port}' | base64 -d
This secret can then be mounted into your application pods, allowing them to connect to the provisioned database without hardcoding any credentials or endpoints. This pattern is fundamental for secure application deployment on Kubernetes, and for further reading on enhancing security, consider our Securing Container Supply Chains with Sigstore and Kyverno guide.
Production Considerations
Deploying Crossplane Compositions in production requires careful planning and adherence to best practices:
- RBAC: Implement strict Kubernetes RBAC for your XRDs and Compositions. Developers should only have permissions to create/manage their namespaced claims (
PostgreSQLInstancein our example), not the cluster-scopedXPostgreSQLInstanceor the underlying managed resources. Platform teams manage the Compositions and XRDs. - Security Groups/Network Policies: The example uses a permissive
0.0.0.0/0ingress rule for simplicity. In production, this must be restricted to specific CIDR blocks, other security groups, or even integrated with Kubernetes Network Policies for pod-to-database communication. Ensure your cloud provider’s network settings align with your security posture. For advanced network encryption between pods, explore Cilium WireGuard Encryption. - VPC and Subnet Management: For real-world use cases, you’ll likely want to create and manage VPCs, subnets, and database subnet groups via Crossplane as well, making them part of a larger composition or managed by another team’s composition.
- ProviderConfig Security: Storing AWS credentials directly in a Kubernetes Secret is acceptable for development but less secure for production. Use IAM Roles for Service Accounts (IRSA) for EKS, Workload Identity for GKE, or Managed Identities for AKS to grant Crossplane the necessary cloud permissions without exposing long-lived credentials.
- Monitoring and Observability: Monitor Crossplane’s health, reconciliation loops, and the status of managed resources. Integrate with your existing observability stack. Crossplane emits standard Kubernetes events, and its metrics can be scraped by Prometheus. For eBPF-based observability, tools like Hubble can provide deep insights, as discussed in eBPF Observability: Building Custom Metrics with Hubble.
- Versioning and Rollbacks: Treat your XRDs and Compositions as critical infrastructure code. Store them in Git, implement GitOps principles, and use proper versioning. Plan for rollback strategies in case of issues.
- Cost Optimization: Compositions can enforce cost-effective defaults (e.g., specific instance types, storage tiers). Combine Crossplane with tools like Karpenter for Kubernetes node autoscaling to further optimize cloud spending.
- Idempotency and Drift Detection: Crossplane ensures idempotency by continuously reconciling the desired state with the actual state. It will detect and attempt to correct any drift in your cloud resources.
- Managed Resource Defaults: Carefully choose default values in your Compositions (e.g.,
dbInstanceClass,allocatedStorage). These defaults establish your organization’s baseline for performance and cost. - Extending to Other Resources: Consider extending your Compositions to include other resources like S3 buckets, SQS queues, or even Kubernetes resources like Deployments and Services using the
provider-kubernetes. This allows for provisioning entire application stacks. For complex networking setups, the Kubernetes Gateway API can be composed with cloud load balancers. - Service Mesh Integration: For applications that utilize the provisioned database, consider integrating with a service mesh like Istio for advanced traffic management, observability, and security. Our Istio Ambient Mesh Production Guide offers insights into modern service mesh deployments.
Troubleshooting
Here are some common issues you might encounter with Crossplane Compositions and their solutions:
-
Issue:
XPostgreSQLInstanceorPostgreSQLInstancestuck inREADY: False.Solution:
This usually means the