Containerization AWS

Deploy Crossplane Providers and Compositions to Provision AWS RDS from Kubernetes

A fintech platform team is drowning in database tickets. Every time a squad needs a Postgres instance for a new service, they file a request, a platform engineer hand-writes Terraform, it sits in a review queue for two days, and the squad waits. Multiply that by forty squads and you have a platform team that is a bottleneck instead of a force multiplier. The mandate from the head of platform is blunt: “I want a developer to get a compliant, encrypted, backed-up RDS instance by committing a five-line YAML file — and I never want to see a database ticket again.” This guide builds exactly that: a self-service RDS provisioning API on Kubernetes using Crossplane, where the platform team owns the Composition (the golden, compliant blueprint) and application teams consume a tiny claim. The result is Terraform-quality infrastructure exposed as a first-class Kubernetes resource, governed end to end.

The pattern matters because it inverts the usual trade-off. Hand-written Terraform is correct but slow; a raw “do whatever you want” cloud account is fast but ungoverned. Crossplane’s Composition layer lets the platform team encode the guardrails once — encryption on, public access off, backups retained, instance classes constrained — and then every developer claim inherits them automatically. The control plane reconciles continuously, so drift is corrected without anyone running apply.

Prerequisites

Target topology

Deploy Crossplane Providers and Compositions to Provision AWS RDS from Kubernetes — topology

The management cluster runs the Crossplane control plane and provider-aws. A platform engineer installs the provider and applies two cluster-scoped artifacts: a CompositeResourceDefinition (XRD) that declares the XPostgreSQLInstance API and its namespaced claim PostgreSQLInstance, and a Composition that maps one claim into the real managed resources — an Instance (RDS), a SubnetGroup, a SecurityGroup, and a KMS-backed encryption config. Crossplane authenticates to AWS via IRSA (IAM Roles for Service Accounts), so no long-lived AWS keys ever live in the cluster. When an application team commits a PostgreSQLInstance claim into their namespace — delivered by Argo CD from a Git repo — Crossplane’s reconcilers call the AWS APIs, create the database, and write the connection secret. Vault then leases those credentials to the consuming workload, Wiz continuously checks the resulting RDS posture against policy, and Dynatrace watches the database and the control plane itself.

1. Install the Crossplane control plane

Install Crossplane into its own namespace with Helm. Crossplane itself is a set of controllers plus a CRD machinery (the Composition engine); it provisions nothing on its own until you add a provider.

helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update

helm install crossplane crossplane-stable/crossplane \
  --namespace crossplane-system \
  --create-namespace \
  --version 1.16.0 \
  --set args='{--enable-usages,--enable-realtime-compositions}' \
  --wait

kubectl get pods -n crossplane-system

You should see crossplane and crossplane-rbac-manager pods running. Confirm the core CRDs landed:

kubectl get crds | grep crossplane.io
# compositeresourcedefinitions.apiextensions.crossplane.io
# compositions.apiextensions.crossplane.io
# providers.pkg.crossplane.io

2. Wire AWS authentication with IRSA (no static keys)

Crossplane’s AWS provider needs AWS credentials. The wrong way is an access key in a Kubernetes Secret. The right way on EKS is IRSA: an IAM role the provider’s ServiceAccount assumes via the cluster’s OIDC provider. First ensure the OIDC provider exists, then create the role with a trust policy scoped to the provider’s ServiceAccount.

CLUSTER=platform-mgmt
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
OIDC=$(aws eks describe-cluster --name $CLUSTER \
  --query 'cluster.identity.oidc.issuer' --output text | sed 's~https://~~')

# Idempotently associate the OIDC provider with the cluster
eksctl utils associate-iam-oidc-provider --cluster $CLUSTER --approve

Create a trust policy. The sub claim must match the ServiceAccount that the AWS provider’s controller runs as — Crossplane generates this name as provider-aws-<hash>, so we use a wildcard on the provider SA prefix in the provider-aws namespace:

cat > /tmp/trust.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::${ACCOUNT_ID}:oidc-provider/${OIDC}" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringLike": {
        "${OIDC}:sub": "system:serviceaccount:crossplane-system:provider-aws-*"
      }
    }
  }]
}
EOF

aws iam create-role --role-name crossplane-provider-aws \
  --assume-role-policy-document file:///tmp/trust.json

# Scope this down in production; broad RDS perms shown for brevity
aws iam attach-role-policy --role-name crossplane-provider-aws \
  --policy-arn arn:aws:iam::aws:policy/AmazonRDSFullAccess
aws iam attach-role-policy --role-name crossplane-provider-aws \
  --policy-arn arn:aws:iam::aws:policy/AmazonVPCFullAccess

In production, replace the AWS-managed policies above with a least-privilege customer-managed policy granting only rds:* on tagged resources, ec2:*SecurityGroup*, ec2:*SubnetGroup* equivalents, and the specific kms: actions your encryption config needs.

3. Install provider-aws (RDS family) and a ProviderConfig

The modern Upbound AWS provider is split into service-scoped packages so you do not pull the entire surface area. We need the RDS family and the EC2 family (for the subnet group and security group). Install both, then a DeploymentRuntimeConfig that annotates the controller’s ServiceAccount with the IAM role from step 2.

# providers.yaml
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-rds
spec:
  package: xpkg.upbound.io/upbound/provider-aws-rds:v1.14.0
  runtimeConfigRef:
    name: irsa-runtime
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-ec2
spec:
  package: xpkg.upbound.io/upbound/provider-aws-ec2:v1.14.0
  runtimeConfigRef:
    name: irsa-runtime
---
apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
metadata:
  name: irsa-runtime
spec:
  serviceAccountTemplate:
    metadata:
      annotations:
        eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT_ID:role/crossplane-provider-aws

Apply it and wait for the providers to become healthy (Crossplane downloads the package, installs the CRDs, and starts the controller):

sed -i '' "s/ACCOUNT_ID/${ACCOUNT_ID}/" providers.yaml
kubectl apply -f providers.yaml

kubectl get providers
kubectl wait provider/provider-aws-rds --for=condition=Healthy --timeout=300s

Now point the providers at AWS with a ProviderConfig that uses the IRSA identity (source: IRSA means “use the pod’s web-identity token,” i.e. no secret):

# providerconfig.yaml
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: IRSA
kubectl apply -f providerconfig.yaml

4. Define the XRD — the self-service API contract

The CompositeResourceDefinition is the public API your developers see. It declares a composite type XPostgreSQLInstance and, crucially, a claim type PostgreSQLInstance that is namespaced — so an app team in their own namespace can request a database. The schema is deliberately minimal: developers choose a size class and a Postgres version, and nothing else. Everything compliance cares about is hidden in the Composition.

# xrd.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xpostgresqlinstances.database.kloudvin.io
spec:
  group: database.kloudvin.io
  names:
    kind: XPostgreSQLInstance
    plural: xpostgresqlinstances
  claimNames:
    kind: PostgreSQLInstance
    plural: postgresqlinstances
  connectionSecretKeys:
    - host
    - port
    - username
    - password
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                parameters:
                  type: object
                  properties:
                    size:
                      type: string
                      description: T-shirt size for the instance.
                      enum: ["small", "medium", "large"]
                    engineVersion:
                      type: string
                      default: "16.3"
                    storageGB:
                      type: integer
                      default: 50
                  required: [size]
              required: [parameters]
kubectl apply -f xrd.yaml
kubectl get xrd xpostgresqlinstances.database.kloudvin.io
# Wait for ESTABLISHED=True and OFFERED=True (the claim CRD is now live)

5. Author the Composition — the golden, compliant blueprint

This is where the platform team’s expertise lives. The Composition maps one XPostgreSQLInstance into the real managed resources and bakes in every guardrail: storage encryption on, public access off, deletion protection on, automated backups retained for 14 days, and a size-to-instance-class mapping so small can never accidentally become a db.r6g.16xlarge. We use Composition Functions (the current, patch-and-transform successor) via function-patch-and-transform.

First install the function:

cat <<EOF | kubectl apply -f -
apiVersion: pkg.crossplane.io/v1beta1
kind: Function
metadata:
  name: function-patch-and-transform
spec:
  package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.7.0
EOF
kubectl wait function/function-patch-and-transform --for=condition=Healthy --timeout=180s

Now the Composition. Note the security-relevant fields are constants in the Composition, not exposed to the claim — a developer cannot turn them off:

# composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: postgres-aws
  labels:
    provider: aws
spec:
  compositeTypeRef:
    apiVersion: database.kloudvin.io/v1alpha1
    kind: XPostgreSQLInstance
  writeConnectionSecretsToNamespace: crossplane-system
  mode: Pipeline
  pipeline:
    - step: patch-and-transform
      functionRef:
        name: function-patch-and-transform
      input:
        apiVersion: pt.fn.crossplane.io/v1beta1
        kind: Resources
        resources:
          - name: subnetgroup
            base:
              apiVersion: rds.aws.upbound.io/v1beta1
              kind: SubnetGroup
              spec:
                forProvider:
                  region: ap-south-1
                  description: Managed by Crossplane
                  subnetIds:
                    - subnet-0aaa1111bbbb2222c
                    - subnet-0ddd3333eeee4444f
          - name: rdsinstance
            base:
              apiVersion: rds.aws.upbound.io/v1beta1
              kind: Instance
              spec:
                forProvider:
                  region: ap-south-1
                  engine: postgres
                  dbSubnetGroupNameSelector:
                    matchControllerRef: true
                  # --- Hard guardrails: not exposed to the claim ---
                  storageEncrypted: true
                  publiclyAccessible: false
                  deletionProtection: true
                  backupRetentionPeriod: 14
                  storageType: gp3
                  autoMinorVersionUpgrade: true
                  username: pgadmin
                  autogeneratePassword: true
                  passwordSecretRef:
                    namespace: crossplane-system
                    name: pg-master-pw
                    key: password
                  skipFinalSnapshot: false
                writeConnectionSecretToRef:
                  namespace: crossplane-system
            connectionDetails:
              - name: host
                type: FromConnectionSecretKey
                fromConnectionSecretKey: endpoint
              - name: port
                type: FromConnectionSecretKey
                fromConnectionSecretKey: port
              - name: username
                type: FromConnectionSecretKey
                fromConnectionSecretKey: username
              - name: password
                type: FromConnectionSecretKey
                fromConnectionSecretKey: attribute.password
            patches:
              - type: FromCompositeFieldPath
                fromFieldPath: spec.parameters.engineVersion
                toFieldPath: spec.forProvider.engineVersion
              - type: FromCompositeFieldPath
                fromFieldPath: spec.parameters.storageGB
                toFieldPath: spec.forProvider.allocatedStorage
              # size -> instance class mapping (developers pick a t-shirt size only)
              - type: FromCompositeFieldPath
                fromFieldPath: spec.parameters.size
                toFieldPath: spec.forProvider.instanceClass
                transforms:
                  - type: map
                    map:
                      small: db.t3.medium
                      medium: db.r6g.large
                      large: db.r6g.2xlarge
              - type: ToCompositeFieldPath
                fromFieldPath: status.atProvider.endpoint
                toFieldPath: status.address
kubectl apply -f composition.yaml

The Composition references a master password Secret (pg-master-pw). In a real platform you do not hand-create this — you let Vault generate and rotate it. With the Vault Secrets Operator or External Secrets, sync a Vault KV/transit-generated value into the pg-master-pw Secret in crossplane-system. That keeps the database master password short-lived and audited in Vault rather than typed into a manifest.

6. Consume it — the developer experience

This is the entire surface an application team touches. They commit this five-line claim into their namespace and get a compliant database. No Terraform, no ticket, no AWS console.

# claim.yaml — lives in the squad's app repo
apiVersion: database.kloudvin.io/v1alpha1
kind: PostgreSQLInstance
metadata:
  name: orders-db
  namespace: team-orders
spec:
  parameters:
    size: medium
    engineVersion: "16.3"
    storageGB: 100
  writeConnectionSecretToRef:
    name: orders-db-conn
kubectl apply -f claim.yaml

Watch Crossplane reconcile the claim into a composite and then into AWS managed resources:

kubectl get postgresqlinstance -n team-orders orders-db -w
kubectl get xpostgresqlinstance      # the cluster-scoped composite
kubectl get instance.rds.aws.upbound.io   # the actual RDS managed resource

Provisioning RDS takes several minutes; the claim’s READY column flips to True when the database is available and the connection secret orders-db-conn is written into team-orders. The application’s pods consume host/port/username/password from that secret — or, better, Vault brokers per-request dynamic database credentials so the long-lived master is never used by app code at all.

7. Deliver claims via GitOps (Argo CD)

Manually running kubectl apply defeats the purpose. The production flow is GitOps: the squad’s claim lives in Git, and Argo CD syncs it. Pull requests are gated by GitHub Actions running kubeconform/kubectl --dry-run=server and an OPA/Conftest policy check; an approved merge triggers ServiceNow change tracking automatically.

# argo-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: team-orders-databases
  namespace: argocd
spec:
  project: platform
  source:
    repoURL: https://github.com/kloudvin/team-orders-infra.git
    targetRevision: main
    path: databases
  destination:
    server: https://kubernetes.default.svc
    namespace: team-orders
  syncPolicy:
    automated: { prune: true, selfHeal: true }
kubectl apply -f argo-app.yaml
argocd app sync team-orders-databases

With selfHeal: true, if anyone edits the claim by hand, Argo CD reverts it to the Git state and Crossplane reconciles RDS back to match — declarative all the way down. For the platform team’s own artifacts (XRD, Composition, providers), use a separate Argo Application so provider upgrades go through the same review gate. Jenkins can serve the same role where a team’s existing CI is Jenkins-based: a pipeline stage that validates and promotes the claim manifest before Argo syncs it.

Validation

Confirm the whole chain is healthy, from claim down to the AWS resource.

# 1. The claim is bound and ready
kubectl get postgresqlinstance -n team-orders orders-db \
  -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'   # -> True

# 2. The managed RDS resource is synced and ready
kubectl get instance.rds.aws.upbound.io -o wide

# 3. Inspect events if anything is stuck
kubectl describe postgresqlinstance -n team-orders orders-db
kubectl get managed   # every Crossplane managed resource and its READY/SYNCED state

# 4. Verify the guardrails actually took effect on the real DB
DBID=$(kubectl get instance.rds.aws.upbound.io \
  -o jsonpath='{.items[0].status.atProvider.id}')
aws rds describe-db-instances --db-instance-identifier "$DBID" \
  --query 'DBInstances[0].{Encrypted:StorageEncrypted,Public:PubliclyAccessible,Backup:BackupRetentionPeriod,DelProt:DeletionProtection}'
# Expect: Encrypted=true, Public=false, Backup=14, DelProt=true

The last check is the important one: it proves a developer’s claim produced an encrypted, private, backed-up database because the Composition forced it, regardless of what the developer asked for. Confirm the connection secret exists for the app: kubectl get secret orders-db-conn -n team-orders.

Rollback / teardown

Because Crossplane owns the lifecycle, you tear down by deleting the claim — Crossplane cascades the deletion to the RDS instance, subnet group, and security group it created. Mind deletion protection and the final snapshot: with deletionProtection: true and skipFinalSnapshot: false, AWS will refuse to delete until you handle both.

# Graceful: delete the claim; Crossplane garbage-collects the AWS resources
kubectl delete postgresqlinstance -n team-orders orders-db

# If deletion hangs on protection, patch it off on the managed resource first
kubectl patch instance.rds.aws.upbound.io <name> --type merge \
  -p '{"spec":{"forProvider":{"deletionProtection":false}}}'

# Watch the AWS resources drain
kubectl get managed -w

To roll back the platform (providers/XRD/Composition), delete the Composition and XRD only after all claims are gone — orphaning claims leaves RDS instances with no controller to manage them. Finally remove the providers and Crossplane:

kubectl delete composition postgres-aws
kubectl delete xrd xpostgresqlinstances.database.kloudvin.io
kubectl delete provider provider-aws-rds provider-aws-ec2
helm uninstall crossplane -n crossplane-system

Always verify in the AWS console or via aws rds describe-db-instances that nothing was orphaned — a stranded RDS instance keeps billing.

Common pitfalls

Security notes

Identity is the foundation: the control plane authenticates to AWS via IRSA, so there are no static AWS keys anywhere in the cluster, and platform engineers themselves reach the cluster through SSO from Okta federated to Entra ID, with RBAC limiting who can edit Compositions versus who can only file claims. The database master password is generated and rotated by HashiCorp Vault and surfaced to Crossplane as a short-lived synced Secret; application workloads should consume Vault dynamic database credentials rather than the master, so each app gets its own expiring login. The Composition encodes the non-negotiable controls — storageEncrypted: true (KMS), publiclyAccessible: false, deletion protection, and backups — as constants developers cannot override, which is the entire security value of the abstraction. Layer continuous verification on top: Wiz (and Wiz Code scanning the Composition manifests in the repo pre-merge) flags any RDS instance that drifts to public exposure or unencrypted storage and any IAM over-permissioning on the provider role, while CrowdStrike Falcon sensors on the management cluster’s nodes provide runtime threat detection for the control plane itself. Pair this with an OPA/Gatekeeper or Kyverno admission policy that rejects any claim or composite missing required tags, so even a malformed claim cannot create an untagged, unattributable database.

Cost notes

The size-to-instance-class map in the Composition is your primary cost lever: by exposing only small/medium/large and mapping them to vetted classes (db.t3.medium, db.r6g.large, db.r6g.2xlarge), you prevent a developer from accidentally provisioning a 16xlarge — the single most common cloud-database bill shock. Enforce mandatory cost-allocation tags (team, environment, cost-center) via the Composition and an admission policy so every RDS instance shows up correctly in Cost Explorer and in the chargeback dashboard. Storage is gp3 (cheaper and more predictable than io1 for most workloads); set a sane storageGB default and require justification for large overrides. Crucially, because teardown is kubectl delete on a claim, decommissioning is friction-free — the usual driver of waste, orphaned databases that nobody dares delete, largely disappears when Crossplane owns the lifecycle. Feed RDS metrics and the Crossplane controllers’ own telemetry into Dynatrace (or Datadog) to spot idle or over-provisioned instances, and raise a ServiceNow request automatically when an instance sits below a utilization threshold for a sustained window so a human confirms before it is downsized or retired. The net effect: faster provisioning and lower spend, because governance is built into the golden path instead of bolted on after the bill arrives.

CrossplaneAWS RDSKubernetesPlatform EngineeringGitOpsIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading