Learnixo
Back to blog
Cloud & DevOpsadvanced

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.

LearnixoJune 11, 20269 min read
Platform EngineeringCrossplaneKubernetesIDPSelf-ServiceInfrastructureExternal SecretsGitOps
Share:𝕏

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

Bash
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
EOF

Composite 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.

YAML
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-aws

The 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.

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: 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: port

Developer Workflow: Claim a Database

With the XRD and Composition in place, a developer creates a PostgresDatabase claim — the namespaced resource:

YAML
# 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 namespace
Bash
kubectl 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      4m

The developer references the secret in their Deployment:

YAML
env:
  - name: DATABASE_URL
    valueFrom:
      secretKeyRef:
        name: orders-db-credentials
        key: endpoint
  - name: DATABASE_PASSWORD
    valueFrom:
      secretKeyRef:
        name: orders-db-credentials
        key: password

No 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:

YAML
spec:
  compositionUpdatePolicy: Manual   # on the XRD  existing XRs don't auto-update

Opt individual XRs into the new revision:

Bash
# 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 merge

External 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.

Bash
helm install external-secrets external-secrets/external-secrets \
  --namespace external-secrets \
  --create-namespace

Connect to HashiCorp Vault

YAML
# 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-secrets

Team syncs a secret

YAML
# 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_secret

ESO 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)

YAML
# 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:

YAML
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_key

ClusterResourceUsage: Platform Quotas

Crossplane claims consume real cloud resources and cost money. Use Kubernetes ResourceQuotas scoped to custom resources:

YAML
# 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.yaml

ArgoCD 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.