All Articles

How to Set Up Crossplane for Self-Service Infrastructure on Kubernetes

A step-by-step tutorial on setting up Crossplane to provision and manage cloud infrastructure directly from Kubernetes. Build a self-service platform where developers can request AWS, GCP, or Azure resources through kubectl.

DevOpsBoysMar 17, 20268 min read
Share:Tweet

Your developers want to spin up an RDS database. Today they either file a Jira ticket and wait two days, or they hack together Terraform in a repo they barely understand. Neither option is good.

Crossplane solves this by bringing infrastructure provisioning into Kubernetes. Developers create a YAML manifest — just like they'd create a Deployment or Service — and Crossplane provisions the actual cloud resource. An S3 bucket, an RDS instance, a VPC, a DNS record — all managed through kubectl.

This tutorial walks you through setting up Crossplane from scratch, connecting it to AWS, and building a self-service infrastructure platform.


What Crossplane Actually Does

Crossplane is a Kubernetes operator that extends your cluster with Custom Resource Definitions (CRDs) for cloud resources. When you apply a manifest, Crossplane's controllers talk to the cloud provider API and create the real resource.

The key concepts:

  • Provider — a plugin that knows how to talk to a specific cloud (AWS, GCP, Azure, etc.)
  • Managed Resource — a Kubernetes resource that maps to a single cloud resource (e.g., RDSInstance, S3Bucket)
  • Composite Resource (XR) — a custom abstraction you define that bundles multiple managed resources together (e.g., "Database" = RDS instance + subnet group + security group)
  • Claim (XRC) — the developer-facing API. Developers create claims, and Crossplane creates the underlying composite resources

The power is in the abstraction layer:

Developer creates:    Platform team defines:      Crossplane provisions:
┌─────────────┐      ┌───────────────────┐       ┌─────────────────────┐
│ DatabaseClaim│ ──→  │ CompositeDatabase │  ──→  │ RDS Instance        │
│ name: mydb   │      │ (XR Definition)   │       │ Subnet Group        │
│ size: small  │      │                   │       │ Security Group      │
│ engine: pg   │      │                   │       │ Parameter Group     │
└─────────────┘      └───────────────────┘       └─────────────────────┘

Developers don't need to know about subnet groups or security groups. They just say "I want a small PostgreSQL database" and the platform team's abstractions handle the rest.


Prerequisites

Before you start, you need:

  • A Kubernetes cluster (1.28+) — EKS, GKE, AKS, or even a local kind cluster for testing
  • kubectl configured to talk to your cluster
  • helm v3 installed
  • An AWS account with programmatic access (we'll use AWS in this tutorial, but the concepts apply to any cloud)

Step 1: Install Crossplane

Install Crossplane using Helm:

bash
# Add the Crossplane Helm repo
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
 
# Install Crossplane
helm install crossplane \
  crossplane-stable/crossplane \
  --namespace crossplane-system \
  --create-namespace \
  --set args='{"--enable-usages"}'

Verify the installation:

bash
# Check that Crossplane pods are running
kubectl get pods -n crossplane-system
 
# You should see:
# crossplane-xxxxxxxxx-xxxxx          1/1  Running
# crossplane-rbac-manager-xxxxx       1/1  Running

Install the Crossplane CLI (optional but helpful):

bash
# Install the crossplane kubectl plugin
curl -sL "https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh" | sh
sudo mv crossplane /usr/local/bin/

Step 2: Install the AWS Provider

Crossplane needs a provider to talk to AWS:

yaml
# aws-provider.yaml
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-s3
spec:
  package: xpkg.upbound.io/upbound/provider-aws-s3:v1.14.0
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-rds
spec:
  package: xpkg.upbound.io/upbound/provider-aws-rds:v1.14.0
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-ec2
spec:
  package: xpkg.upbound.io/upbound/provider-aws-ec2:v1.14.0

Apply it:

bash
kubectl apply -f aws-provider.yaml
 
# Wait for the provider to become healthy
kubectl get providers
# NAME               INSTALLED   HEALTHY   PACKAGE                                          AGE
# provider-aws-s3    True        True      xpkg.upbound.io/upbound/provider-aws-s3:v1.14.0  60s
# provider-aws-rds   True        True      xpkg.upbound.io/upbound/provider-aws-rds:v1.14.0 60s
# provider-aws-ec2   True        True      xpkg.upbound.io/upbound/provider-aws-ec2:v1.14.0 60s

Step 3: Configure AWS Credentials

Create a Kubernetes secret with your AWS credentials:

bash
# Create a credentials file
cat > aws-credentials.txt << EOF
[default]
aws_access_key_id = YOUR_ACCESS_KEY_ID
aws_secret_access_key = YOUR_SECRET_ACCESS_KEY
EOF
 
# Create the Kubernetes secret
kubectl create secret generic aws-creds \
  -n crossplane-system \
  --from-file=creds=./aws-credentials.txt
 
# Clean up the plaintext file
rm aws-credentials.txt

Now create a ProviderConfig that tells the AWS providers how to authenticate:

yaml
# aws-provider-config.yaml
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: aws-creds
      key: creds
bash
kubectl apply -f aws-provider-config.yaml

For production, use IRSA (IAM Roles for Service Accounts) on EKS instead of static credentials:

yaml
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: IRSA

This is more secure because the credentials are automatically rotated and scoped to the specific service account.


Step 4: Provision Your First Resource

Let's start simple — create an S3 bucket:

yaml
# s3-bucket.yaml
apiVersion: s3.aws.upbound.io/v1beta2
kind: Bucket
metadata:
  name: my-crossplane-bucket
spec:
  forProvider:
    region: us-east-1
    tags:
      ManagedBy: crossplane
      Environment: dev
  providerConfigRef:
    name: default
bash
kubectl apply -f s3-bucket.yaml
 
# Watch the bucket get created
kubectl get bucket my-crossplane-bucket -w
 
# NAME                    READY   SYNCED   EXTERNAL-NAME           AGE
# my-crossplane-bucket    True    True     my-crossplane-bucket    45s

When READY and SYNCED are both True, the real S3 bucket exists in AWS. You can verify:

bash
aws s3 ls | grep crossplane
# 2026-03-17 10:15:32 my-crossplane-bucket

To delete it, just delete the Kubernetes resource:

bash
kubectl delete bucket my-crossplane-bucket
# The real S3 bucket gets deleted too

This is the fundamental loop: Kubernetes resources map to cloud resources. Create, update, delete — all through kubectl.


Step 5: Build a Composite Resource (The Abstraction Layer)

Raw managed resources are powerful but verbose. For a self-service platform, you want to create abstractions that hide complexity.

Let's build a "Database" abstraction that creates an RDS instance with all its dependencies.

Define the Composite Resource Definition (XRD)

This defines the API — what parameters developers can set:

yaml
# database-xrd.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xdatabases.platform.devopsboys.com
spec:
  group: platform.devopsboys.com
  names:
    kind: XDatabase
    plural: xdatabases
  claimNames:
    kind: Database
    plural: databases
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                parameters:
                  type: object
                  properties:
                    engine:
                      type: string
                      enum: [postgres, mysql]
                      default: postgres
                    size:
                      type: string
                      enum: [small, medium, large]
                      default: small
                    region:
                      type: string
                      default: us-east-1
                  required:
                    - engine
                    - size

Define the Composition

This defines what gets created when someone requests a Database:

yaml
# database-composition.yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: database-aws
  labels:
    provider: aws
spec:
  compositeTypeRef:
    apiVersion: platform.devopsboys.com/v1alpha1
    kind: XDatabase
  resources:
    - name: securitygroup
      base:
        apiVersion: ec2.aws.upbound.io/v1beta1
        kind: SecurityGroup
        spec:
          forProvider:
            region: us-east-1
            description: "Managed by Crossplane - Database SG"
            ingress:
              - fromPort: 5432
                toPort: 5432
                protocol: tcp
                cidrBlocks:
                  - "10.0.0.0/8"
      patches:
        - fromFieldPath: spec.parameters.region
          toFieldPath: spec.forProvider.region
 
    - name: rdsinstance
      base:
        apiVersion: rds.aws.upbound.io/v1beta2
        kind: Instance
        spec:
          forProvider:
            region: us-east-1
            instanceClass: db.t3.micro
            engine: postgres
            engineVersion: "16"
            allocatedStorage: 20
            publiclyAccessible: false
            skipFinalSnapshot: true
            masterUsername: admin
            masterPasswordSecretRef:
              namespace: crossplane-system
              name: db-master-password
              key: password
      patches:
        - fromFieldPath: spec.parameters.region
          toFieldPath: spec.forProvider.region
        - fromFieldPath: spec.parameters.engine
          toFieldPath: spec.forProvider.engine
        - type: FromCompositeFieldPath
          fromFieldPath: spec.parameters.size
          toFieldPath: spec.forProvider.instanceClass
          transforms:
            - type: map
              map:
                small: db.t3.micro
                medium: db.t3.medium
                large: db.r6g.large

Apply both:

bash
kubectl apply -f database-xrd.yaml
kubectl apply -f database-composition.yaml

Step 6: Developers Request Infrastructure

Now developers can create databases with a simple claim:

yaml
# my-database.yaml
apiVersion: platform.devopsboys.com/v1alpha1
kind: Database
metadata:
  name: orders-db
  namespace: team-backend
spec:
  parameters:
    engine: postgres
    size: medium
    region: us-east-1
bash
kubectl apply -f my-database.yaml
 
# Watch the resources get created
kubectl get database orders-db -n team-backend
kubectl get xdatabase -l crossplane.io/claim-name=orders-db
kubectl get instance  # The actual RDS instance

The developer just requested a production-grade PostgreSQL database with:

  • Proper security group
  • Correct instance sizing
  • All the networking handled

And they did it with 10 lines of YAML. No Terraform. No Jira ticket. No waiting.


Step 7: Track Resource Status

Crossplane provides full visibility into resource state:

bash
# See all managed resources and their status
kubectl get managed
 
# Detailed status of a specific resource
kubectl describe instance orders-db-xxxxx
 
# See the composition tree
crossplane beta trace database orders-db -n team-backend

The crossplane beta trace command shows the full resource tree:

NAME                                          SYNCED   READY   STATUS
Database/orders-db (team-backend)             True     True    Available
└─ XDatabase/orders-db-xxxxx                  True     True    Available
   ├─ SecurityGroup/orders-db-sg-xxxxx        True     True    Available
   └─ Instance/orders-db-rds-xxxxx            True     True    Available

Step 8: Add Drift Detection and Correction

One of Crossplane's most powerful features is continuous reconciliation. If someone manually modifies a resource in the AWS console, Crossplane detects the drift and corrects it.

Test it:

bash
# Manually change something in AWS
aws rds modify-db-instance \
  --db-instance-identifier orders-db-rds-xxxxx \
  --allocated-storage 50
 
# Within a few minutes, Crossplane will detect the drift
# and revert it back to 20 GB (what the manifest says)
kubectl describe instance orders-db-rds-xxxxx | grep "allocated"

This is GitOps for infrastructure — your Kubernetes manifests are the source of truth.


Production Considerations

Before running Crossplane in production, address these:

RBAC — Control Who Can Create What

yaml
# Allow the backend team to create Databases but not raw AWS resources
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: database-creator
  namespace: team-backend
rules:
  - apiGroups: ["platform.devopsboys.com"]
    resources: ["databases"]
    verbs: ["get", "list", "create", "update", "delete"]

Cost Controls — Set Limits on Resource Sizes

Use Kyverno or OPA to enforce policies:

yaml
# Kyverno policy: no large databases in dev namespaces
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: no-large-db-in-dev
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-db-size
      match:
        any:
          - resources:
              kinds:
                - Database
              namespaces:
                - "team-*-dev"
      validate:
        message: "Large databases are not allowed in dev namespaces"
        pattern:
          spec:
            parameters:
              size: "!large"

Backup — Don't Lose Your State

Crossplane state lives in Kubernetes (etcd). Back up your cluster state regularly:

bash
# Use Velero for cluster backup
velero backup create crossplane-backup \
  --include-resources=databases.platform.devopsboys.com,xdatabases.platform.devopsboys.com

Crossplane vs Terraform: When to Use Which

This isn't an either/or choice. They serve different use cases:

Use Crossplane when:

  • You want self-service infrastructure for developers
  • Your platform is Kubernetes-native
  • You need continuous drift detection and correction
  • You want to expose simplified APIs to internal teams

Use Terraform when:

  • You're bootstrapping infrastructure from scratch (VPCs, clusters, accounts)
  • You need complex dependency graphs across many providers
  • Your team is already proficient in Terraform
  • You need to manage resources that don't have Crossplane providers

Many teams use both: Terraform to set up the foundational infrastructure (VPCs, EKS clusters) and Crossplane inside the cluster for day-2 operations (databases, caches, queues).


Wrapping Up

Crossplane turns your Kubernetes cluster into a universal control plane for cloud infrastructure. Developers get self-service access through simple YAML claims. Platform teams define the abstractions and guardrails. Cloud resources are managed with the same GitOps workflows you already use for applications.

The setup takes an afternoon. The payoff — developers shipping faster without waiting for infrastructure tickets — lasts forever.


Want to learn Kubernetes and platform engineering hands-on? KodeKloud's Kubernetes courses cover everything from CKA prep to advanced cluster operations with real lab environments.

Building your platform on cloud infrastructure? DigitalOcean's managed Kubernetes gives you a production-ready cluster in minutes — perfect for running Crossplane.

Newsletter

Stay ahead of the curve

Get the latest DevOps, Kubernetes, AWS, and AI/ML guides delivered straight to your inbox. No spam — just practical engineering content.

Related Articles

Comments