Platform Engineering: Self-Service Infrastructure with Crossplane — XRDs, Compositions, and External Secrets
Build a self-service infrastructure platform with Crossplane — Composite Resource Definitions for developer-facing APIs, Compositions that provision real cloud resources, External Secrets Operator for secrets management, and the full provisioner pattern.
The Self-Service Infrastructure Problem
Platform teams get the same requests repeatedly: "can you provision a PostgreSQL database for my service?" "I need a Redis cache for staging." "We need an S3 bucket with specific IAM permissions."
Manual provisioning doesn't scale. Terraform works but requires platform team involvement for every change. The alternative: give developers a Kubernetes-native API to provision infrastructure themselves — with the platform defining the guardrails.
Crossplane makes this possible. It extends the Kubernetes API with cloud resource types. Developers apply YAML; Crossplane provisions the real cloud infrastructure.
Crossplane Architecture
Developer Platform Team
│ │
│ kubectl apply │ defines
│ DatabaseClaim.yaml │ Composition + XRD
▼ ▼
┌─────────────────────────────────────────────────┐
│ Kubernetes API (extended by Crossplane) │
│ │
│ DatabaseClaim (Composite Resource) │
│ → Composition maps to: │
│ ├── RDSInstance (AWS provider) │
│ ├── SecurityGroup (AWS provider) │
│ └── Secret (K8s secret with credentials) │
└─────────────────────────────────────────────────┘
│
▼ reconciles
┌────────────────────────────┐
│ AWS / Azure / GCP │
│ Real cloud resources │
└────────────────────────────┘Install Crossplane
helm install crossplane crossplane-stable/crossplane \
--namespace crossplane-system \
--create-namespace \
--set args='{--enable-composition-revisions}'
# Install AWS provider
kubectl apply -f - <<EOF
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-aws-rds
spec:
package: xpkg.upbound.io/upbound/provider-aws-rds:v1.0.0
EOF
# Configure AWS credentials
kubectl create secret generic aws-creds \
--namespace crossplane-system \
--from-literal=creds="[default]
aws_access_key_id = $(AWS_ACCESS_KEY_ID)
aws_secret_access_key = $(AWS_SECRET_ACCESS_KEY)"
kubectl apply -f - <<EOF
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: default
spec:
credentials:
source: Secret
secretRef:
namespace: crossplane-system
name: aws-creds
key: creds
EOFComposite Resource Definition (XRD): Developer API
The XRD defines the custom resource that developers interact with. This is the API surface — keep it simple and opinionated. Developers don't need to know about VPCs, subnet groups, or parameter groups.
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xpostgresdatabases.platform.example.com
spec:
group: platform.example.com
names:
kind: XPostgresDatabase
plural: xpostgresdatabases
claimNames: # namespaced version (what developers use)
kind: PostgresDatabase
plural: postgresdatabases
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
parameters:
type: object
required: [size, environment]
properties:
size:
type: string
enum: [small, medium, large] # t3.micro, t3.small, t3.medium
description: "Database instance size tier"
environment:
type: string
enum: [dev, staging, production]
backupRetentionDays:
type: integer
default: 7
minimum: 1
maximum: 35
version:
type: string
default: "15.4"
enum: ["14.0", "15.4", "16.0"]
required: [parameters]
status:
type: object
properties:
endpoint:
type: string
port:
type: integer
connectionSecretRef:
type: string
defaultCompositionRef:
name: postgres-awsThe developer sees a clean PostgresDatabase resource with four fields: size, environment, backupRetentionDays, version. They never see security groups, subnet groups, or parameter groups.
Composition: Platform Implementation
The Composition maps the developer-facing XR to real cloud resources. This is where the platform team encodes best practices: encryption at rest, multi-AZ, private subnets, specific parameter groups.
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: postgres-aws
labels:
provider: aws
db: postgres
spec:
compositeTypeRef:
apiVersion: platform.example.com/v1alpha1
kind: XPostgresDatabase
resources:
# 1. RDS Subnet Group (uses platform VPC subnets)
- name: subnet-group
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: SubnetGroup
spec:
forProvider:
region: eu-west-1
subnetIdRefs:
- name: private-subnet-a
- name: private-subnet-b
- name: private-subnet-c
patches:
- type: FromCompositeFieldPath
fromFieldPath: metadata.uid
toFieldPath: metadata.name
transforms:
- type: string
string:
fmt: "db-subnet-group-%s"
# 2. Security Group (restrict access to cluster only)
- name: security-group
base:
apiVersion: ec2.aws.upbound.io/v1beta1
kind: SecurityGroup
spec:
forProvider:
region: eu-west-1
vpcIdRef:
name: platform-vpc
ingress:
- fromPort: 5432
toPort: 5432
protocol: tcp
cidrBlocks:
- "10.0.0.0/8" # only from cluster CIDR
# 3. RDS Instance
- name: rds-instance
base:
apiVersion: rds.aws.upbound.io/v1beta1
kind: Instance
spec:
forProvider:
region: eu-west-1
engine: postgres
autoMinorVersionUpgrade: true
multiAz: false # patched below based on environment
storageEncrypted: true # always encrypted
deletionProtection: false # patched below
backupWindow: "03:00-04:00"
maintenanceWindow: "mon:04:00-mon:05:00"
vpcSecurityGroupIdSelector:
matchControllerRef: true
dbSubnetGroupNameSelector:
matchControllerRef: true
username: "platformadmin"
passwordSecretRef:
key: password
name: db-admin-password
namespace: crossplane-system
writeConnectionSecretToRef:
namespace: crossplane-system # patched to claim namespace
patches:
# Map size enum to instance class
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.size
toFieldPath: spec.forProvider.instanceClass
transforms:
- type: map
map:
small: db.t3.micro
medium: db.t3.small
large: db.t3.medium
# Map engine version
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.version
toFieldPath: spec.forProvider.engineVersion
# Production: enable multi-AZ and deletion protection
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.environment
toFieldPath: spec.forProvider.multiAz
transforms:
- type: map
map:
dev: "false"
staging: "false"
production: "true"
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.environment
toFieldPath: spec.forProvider.deletionProtection
transforms:
- type: map
map:
dev: "false"
staging: "false"
production: "true"
# Write connection secret to the claim's namespace
- type: FromCompositeFieldPath
fromFieldPath: spec.claimRef.namespace
toFieldPath: spec.writeConnectionSecretToRef.namespace
# Connection secret name = claim name + "-db-credentials"
- type: FromCompositeFieldPath
fromFieldPath: spec.claimRef.name
toFieldPath: spec.writeConnectionSecretToRef.name
transforms:
- type: string
string:
fmt: "%s-db-credentials"
# Backup retention from parameters
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.backupRetentionDays
toFieldPath: spec.forProvider.backupRetentionPeriod
# Publish connection details back to composite
- type: ToCompositeFieldPath
fromFieldPath: status.atProvider.endpoint.address
toFieldPath: status.endpoint
- type: ToCompositeFieldPath
fromFieldPath: status.atProvider.endpoint.port
toFieldPath: status.port
connectionDetails:
- fromConnectionSecretKey: username
- fromConnectionSecretKey: password
- fromConnectionSecretKey: endpoint
- fromConnectionSecretKey: portDeveloper Workflow: Claim a Database
With the XRD and Composition in place, a developer creates a PostgresDatabase claim — the namespaced resource:
# applied by the developer in their namespace
apiVersion: platform.example.com/v1alpha1
kind: PostgresDatabase
metadata:
name: orders-db
namespace: orders
spec:
parameters:
size: medium
environment: production
backupRetentionDays: 14
version: "15.4"
writeConnectionSecretToRef:
name: orders-db-credentials # Secret created in the orders namespacekubectl apply -f orders-db-claim.yaml
# Watch provisioning status
kubectl get postgresdb orders-db -n orders -w
# NAME READY SYNCED ENDPOINT AGE
# orders-db False True <pending> 30s
# orders-db True True orders-db.xyz.eu-west-1.rds.amazonaws.com 4m
# Connection secret is available
kubectl get secret orders-db-credentials -n orders
# NAME TYPE DATA AGE
# orders-db-credentials Opaque 4 4mThe developer references the secret in their Deployment:
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: orders-db-credentials
key: endpoint
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: orders-db-credentials
key: passwordNo Terraform. No platform team involved. The claim is in Git, reviewed in a PR, and synced by ArgoCD like any other resource.
Composition Revisions: Safe Updates
When you update a Composition (e.g., add encryption KMS key), existing databases aren't immediately updated. Composition Revisions give you control:
spec:
compositionUpdatePolicy: Manual # on the XRD — existing XRs don't auto-updateOpt individual XRs into the new revision:
# See available revisions
kubectl get compositionrevisions
# Update specific XRs to the new revision
kubectl patch postgresdb orders-db \
--patch '{"spec":{"compositionRevisionRef":{"name":"postgres-aws-v2"}}}' \
--type mergeExternal Secrets Operator: Secret Management
Crossplane provisions infrastructure. External Secrets Operator (ESO) syncs secrets from Vault, AWS Secrets Manager, or Azure Key Vault into Kubernetes Secrets.
helm install external-secrets external-secrets/external-secrets \
--namespace external-secrets \
--create-namespaceConnect to HashiCorp Vault
# ClusterSecretStore: platform-level Vault connection
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-backend
spec:
provider:
vault:
server: "https://vault.platform.example.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "external-secrets"
serviceAccountRef:
name: external-secrets
namespace: external-secretsTeam syncs a secret
# Applied by the team in their namespace
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: payment-api-keys
namespace: payments
spec:
refreshInterval: 1h # re-sync from Vault every hour
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: payment-api-keys # created Kubernetes Secret name
creationPolicy: Owner # ESO owns and manages this secret
data:
- secretKey: stripe-api-key # key in the K8s secret
remoteRef:
key: payments/stripe # Vault path
property: api_key # field within the Vault secret
- secretKey: stripe-webhook-secret
remoteRef:
key: payments/stripe
property: webhook_secretESO creates and keeps the Kubernetes Secret in sync. If the Vault secret is rotated, ESO syncs the new value on the next refresh interval — no pod restart required (assuming the app reads the secret from the filesystem, which Kubernetes remounts automatically).
SecretStore per namespace (team isolation)
# Each team gets a SecretStore scoped to their Vault path
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: team-vault
namespace: payments
spec:
provider:
vault:
server: "https://vault.platform.example.com"
path: "secret/payments" # scoped to payments path
version: "v2"
auth:
kubernetes:
role: "payments-team"The payments-team Vault role only has access to secret/payments/*. A team cannot read secrets from another team's path.
PushSecret: Write Secrets to Vault
ESO also supports pushing secrets to external stores — useful for bootstrapping:
apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
name: app-generated-secret
namespace: payments
spec:
secretStoreRefs:
- name: vault-backend
kind: ClusterSecretStore
selector:
secret:
name: payment-service-generated-key
data:
- match:
secretKey: api_key
remoteRef:
remoteKey: payments/generated
property: api_keyClusterResourceUsage: Platform Quotas
Crossplane claims consume real cloud resources and cost money. Use Kubernetes ResourceQuotas scoped to custom resources:
# Limit each namespace to 3 databases max
apiVersion: v1
kind: ResourceQuota
metadata:
name: database-quota
namespace: payments
spec:
hard:
count/postgresdatabases.platform.example.com: "3"
count/rediscaches.platform.example.com: "2"Developers cannot provision more than the quota allows — the platform controls cloud spend boundaries.
Platform Composition Library
Structure compositions as a versioned library:
crossplane-library/
├── compositions/
│ ├── postgres/
│ │ ├── aws/
│ │ │ ├── v1/composition.yaml
│ │ │ └── v2/composition.yaml ← adds KMS encryption
│ │ └── azure/
│ │ └── v1/composition.yaml
│ ├── redis/
│ │ └── aws/elasticache/
│ ├── s3-bucket/
│ └── kafka-topic/ ← Strimzi-based, not AWS
├── xrds/
│ ├── postgres.yaml
│ ├── redis.yaml
│ └── s3bucket.yaml
└── providers/
├── aws-provider.yaml
└── azure-provider.yamlArgoCD syncs this repo to every cluster. A new composition version PR goes through review before reaching production.
Crossplane vs Terraform: When to Use Each
| Scenario | Crossplane | Terraform | |---|---|---| | Developer self-service | ✅ Kubernetes-native claim | ❌ Requires PR + platform team | | Complex multi-resource topology | Possible but verbose | ✅ Better HCL support | | Drift correction | ✅ Continuous reconciliation | ❌ Requires manual plan/apply | | State management | In Kubernetes (etcd) | Separate state backend | | Non-K8s resources (VMs, DNS) | ❌ Limited | ✅ Full coverage | | Existing Terraform codebase | Bridge: Upbound Terraform provider | ✅ |
The pragmatic answer: use Crossplane for developer-facing self-service (databases, caches, buckets), and Terraform for foundational infrastructure (VPCs, EKS clusters, IAM roles) that the platform team manages. They complement rather than replace each other.
Enjoyed this article?
Explore the Cloud & DevOps learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.