Build a platform with Upbound
In this tutorial, you deploy an application with a PostgreSQL database on AWS, watch Crossplane self-heal a manually changed resource, enforce a security policy, and change live infrastructure — all by updating YAML files.
By the end of this tutorial, you can:
- Deploy a composite resource that creates multiple AWS resources from a single manifest
- Explore the providers and ProviderConfigs that connect your platform to AWS
- Trigger drift detection and watch Crossplane correct an out-of-band change
- Block non-compliant requests with Kyverno before they reach Crossplane
- Update live infrastructure by changing desired state
Prerequisites
Install the following tools before starting:
kubectl- AWS CLI, configured with credentials for an account where you can create VPCs, IAM roles, and RDS instances
- kind
Install the up CLI
curl -sL "https://cli.upbound.io" | sh
Move the binary into your PATH:
sudo mv up /usr/local/bin/
If you don't have sudo access:
mkdir -p ~/.local/bin && mv up ~/.local/bin/
export PATH="$HOME/.local/bin:$PATH"
Add the export line to your shell profile (~/.bashrc, ~/.zshrc, or
equivalent) to make it permanent.
Set up the project
Create the project directory
mkdir platform-demo
cd platform-demo
All commands from this point run from inside platform-demo.
Create the project manifest
The upbound.yaml file declares the project name and its provider and function
dependencies. up project run --local reads this file to install packages into
the cluster.
cat > upbound.yaml <<'EOF'
apiVersion: meta.dev.upbound.io/v2alpha1
kind: Project
metadata:
name: app-w-db
spec:
repository: xpkg.upbound.io/upbound/app-w-db
apiDependencies:
- k8s:
version: v1.33.0
type: k8s
dependsOn:
- apiVersion: pkg.crossplane.io/v1
kind: Provider
package: xpkg.upbound.io/upbound/provider-family-aws
version: v2.4.0
- apiVersion: pkg.crossplane.io/v1
kind: Provider
package: xpkg.upbound.io/upbound/provider-aws-iam
version: v2.4.0
- apiVersion: pkg.crossplane.io/v1
kind: Provider
package: xpkg.upbound.io/upbound/provider-aws-rds
version: v2.4.0
- apiVersion: pkg.crossplane.io/v1
kind: Provider
package: xpkg.upbound.io/upbound/provider-aws-ec2
version: v2.4.0
- apiVersion: pkg.crossplane.io/v1beta1
kind: Function
package: xpkg.upbound.io/crossplane-contrib/function-auto-ready
version: v0.6.1
description: A Crossplane composition that provisions a web application with a
managed database (RDS), networking (VPC/Subnets), IAM role, and a Kubernetes Deployment.
license: Apache-2.0
maintainer: Upbound User <user@example.com>
EOF
Define the platform APIs
The platform exposes two APIs: AppWDB (a basic app with a database) and
AppWDBSecure (the same API with an optional security context, used later for
policy enforcement).
Create the AppWDB XRD:
mkdir -p apis/appwdb
cat > apis/appwdb/definition.yaml <<'EOF'
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: appwdbs.demo.upbound.io
spec:
group: demo.upbound.io
names:
categories:
- crossplane
kind: AppWDB
plural: appwdbs
scope: Namespaced
versions:
- name: v1alpha1
referenceable: true
schema:
openAPIV3Schema:
description: AppWDB is the Schema for the AppWDB API.
properties:
spec:
description: AppWDBSpec defines the desired state of AppWDB.
type: object
properties:
parameters:
type: object
description: AppWDB configuration parameters
properties:
replicas:
type: integer
default: 2
description: Number of app replicas
dbSize:
type: string
default: db.t3.micro
enum:
- db.t3.micro
- db.t3.small
- db.t3.medium
description: RDS instance class
region:
type: string
default: eu-central-1
description: AWS region
required:
- parameters
status:
description: AppWDBStatus defines the observed state of AppWDB.
type: object
required:
- spec
type: object
served: true
EOF
Create the AppWDBSecure XRD:
mkdir -p apis/appwdbsecure
cat > apis/appwdbsecure/definition.yaml <<'EOF'
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: appwdbsecures.demo.upbound.io
spec:
group: demo.upbound.io
names:
categories:
- crossplane
kind: AppWDBSecure
plural: appwdbsecures
scope: Namespaced
versions:
- name: v1alpha1
referenceable: true
schema:
openAPIV3Schema:
description: AppWDBSecure is the Schema for the AppWDBSecure API.
properties:
spec:
description: AppWDBSecureSpec defines the desired state of AppWDBSecure.
type: object
properties:
parameters:
type: object
description: AppWDBSecure configuration parameters
properties:
replicas:
type: integer
default: 2
description: Number of app replicas
dbSize:
type: string
default: db.t3.micro
enum:
- db.t3.micro
- db.t3.small
- db.t3.medium
description: RDS instance class
region:
type: string
default: eu-central-1
description: AWS region
securityContext:
type: object
description: Optional security context for the application container
properties:
privileged:
type: boolean
description: Run container as privileged. Blocked by platform policy.
required:
- parameters
status:
description: AppWDBSecureStatus defines the observed state of AppWDBSecure.
type: object
required:
- spec
type: object
served: true
EOF
Create the composition function
The composition function is a KCL program that maps the user's 10-line request to the full set of AWS resources.
mkdir -p functions/compose-resources
cat > functions/compose-resources/kcl.mod <<'EOF'
[package]
name = "compose-resources"
version = "0.1.0"
EOF
Create main.k. This file is the entire composition logic — it reads the
composite resource and outputs every managed resource Crossplane creates:
cat > functions/compose-resources/main.k <<'EOF'
oxr = option("params").oxr
ocds = option("params").ocds
params = oxr.spec.parameters
appName = oxr.metadata.name
region = params.region or "eu-central-1"
dbSize = params.dbSize or "db.t3.micro"
replicas = params.replicas or 2
_is_deleting = bool(oxr.metadata?.deletionTimestamp)
_db_key = "${appName}-db"
_instance_still_exists = _db_key in ocds
_metadata = lambda name: str -> any {
{
namespace: oxr.metadata.namespace
annotations: {"krm.kcl.dev/composition-resource-name": name}
}
}
_defaults = {
managementPolicies: ["*"]
providerConfigRef: {kind: "ProviderConfig", name: "default"}
}
_subnets = [
{cidrBlock: "10.0.1.0/24", availabilityZone: "${region}a", suffix: "a"}
{cidrBlock: "10.0.2.0/24", availabilityZone: "${region}b", suffix: "b"}
{cidrBlock: "10.0.3.0/24", availabilityZone: "${region}c", suffix: "c"}
]
_sg_items = [{
apiVersion: "rds.aws.m.upbound.io/v1beta1"
kind: "SubnetGroup"
metadata: _metadata("${appName}-subnet-group") | {name: "${appName}-subnet-group"}
spec: _defaults | {
forProvider: {
region: region
description: "${appName} DB subnet group"
subnetIdSelector: {matchControllerRef: True}
}
}
}] if not _is_deleting or _instance_still_exists else []
_db_items = [{
apiVersion: "rds.aws.m.upbound.io/v1beta1"
kind: "Instance"
metadata: _metadata("${appName}-db") | {
name: "${appName}-db"
annotations: {"crossplane.io/external-name": "${appName}-db"}
}
spec: _defaults | {
forProvider: {
region: region
identifier: "${appName}-db"
engine: "postgres"
engineVersion: "16.6"
instanceClass: dbSize
username: "demoadmin"
dbName: "appdb"
autoGeneratePassword: True
passwordSecretRef: {namespace: oxr.metadata.namespace, name: "${appName}-db-password", key: "password"}
applyImmediately: True
skipFinalSnapshot: True
allocatedStorage: 20
storageType: "gp3"
storageEncrypted: False
publiclyAccessible: False
backupRetentionPeriod: 0
dbSubnetGroupNameSelector: {matchControllerRef: True}
}
initProvider: {identifier: "${appName}-db"}
}
}] if not _is_deleting else []
_items = [
{
apiVersion: "ec2.aws.m.upbound.io/v1beta1"
kind: "VPC"
metadata: _metadata("${appName}-vpc") | {name: "${appName}-vpc"}
spec: _defaults | {
forProvider: {
region: region
cidrBlock: "10.0.0.0/16"
enableDnsHostnames: True
enableDnsSupport: True
tags: {"Name": "${appName}-vpc"}
}
}
}
] + [
{
apiVersion: "ec2.aws.m.upbound.io/v1beta1"
kind: "Subnet"
metadata: _metadata("${appName}-subnet-${s.suffix}") | {name: "${appName}-subnet-${s.suffix}"}
spec: _defaults | {
forProvider: {
region: region
cidrBlock: s.cidrBlock
availabilityZone: s.availabilityZone
vpcIdSelector: {matchControllerRef: True}
tags: {"Name": "${appName}-subnet-${s.suffix}"}
}
}
} for s in _subnets
] + _sg_items + _db_items + [
{
apiVersion: "iam.aws.m.upbound.io/v1beta1"
kind: "Role"
metadata: _metadata("${appName}-role") | {name: "${appName}-role"}
spec: _defaults | {
forProvider: {
assumeRolePolicy: '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}'
}
}
}
{
apiVersion: "apps/v1"
kind: "Deployment"
metadata: _metadata("${appName}-deployment") | {name: appName}
spec: {
replicas: replicas
selector: {matchLabels: {app: appName}}
template: {
metadata: {labels: {app: appName}}
spec: {
containers: [
{
name: "app"
image: "public.ecr.aws/nginx/nginx:stable-alpine"
ports: [{containerPort: 80}]
} | ({securityContext: {privileged: params.securityContext.privileged}} if params?.securityContext?.privileged != None else {})
]
}
}
}
}
]
items = _items
EOF
Create example manifests
Create the base example and change variants for later steps:
mkdir -p examples/appwdb
cat > examples/appwdb/example.yaml <<'EOF'
apiVersion: demo.upbound.io/v1alpha1
kind: AppWDB
metadata:
name: demo-01
namespace: demo
spec:
parameters:
replicas: 2
dbSize: db.t3.micro
region: eu-central-1
EOF
cat > examples/appwdb/variant-bigger-db.yaml <<'EOF'
apiVersion: demo.upbound.io/v1alpha1
kind: AppWDB
metadata:
name: demo-01
namespace: demo
spec:
parameters:
replicas: 2
dbSize: db.t3.medium
region: eu-central-1
EOF
cat > examples/appwdb/variant-more-replicas.yaml <<'EOF'
apiVersion: demo.upbound.io/v1alpha1
kind: AppWDB
metadata:
name: demo-01
namespace: demo
spec:
parameters:
replicas: 5
dbSize: db.t3.micro
region: eu-central-1
EOF
Create the secure examples used in the policy enforcement step:
mkdir -p examples/appwdbsecure
cat > examples/appwdbsecure/example-1.yaml <<'EOF'
apiVersion: demo.upbound.io/v1alpha1
kind: AppWDBSecure
metadata:
name: kyverno-demo-01
namespace: demo
spec:
parameters:
replicas: 2
dbSize: db.t3.micro
region: eu-central-1
securityContext:
privileged: true
EOF
cat > examples/appwdbsecure/example-2.yaml <<'EOF'
apiVersion: demo.upbound.io/v1alpha1
kind: AppWDBSecure
metadata:
name: kyverno-demo-01
namespace: demo
spec:
parameters:
replicas: 2
dbSize: db.t3.micro
region: eu-central-1
securityContext:
privileged: false
EOF
Create the ProviderConfig
The ProviderConfig tells the AWS providers where to find credentials.
mkdir -p setup/config
cat > setup/config/aws-provider-config.yaml <<'EOF'
apiVersion: aws.m.upbound.io/v1beta1
kind: ProviderConfig
metadata:
name: default
namespace: demo
spec:
credentials:
source: Secret
secretRef:
namespace: demo
name: aws-secret
key: creds
EOF
Configure AWS credentials
The demo creates real AWS resources. Export credentials with permissions to create VPCs, subnets, IAM roles, and RDS instances:
export AWS_ACCESS_KEY_ID=<your-access-key-id>
export AWS_SECRET_ACCESS_KEY=<your-secret-access-key>
Start the project
Open a dedicated terminal window and run from inside platform-demo:
up project run --local --ingress
This command:
- Creates a kind cluster named
up-app-w-db - Installs UXP into the cluster
- Builds and deploys the KCL composition function
- Installs the AWS providers declared in
upbound.yaml - Applies the XRDs from
apis/ - Installs an ingress controller for the UXP console
Startup takes several minutes. Keep this terminal open throughout the tutorial.
up project run --local may print traces export: context deadline exceeded
in stderr. This is a non-fatal telemetry export error. Check whether providers
were installed with kubectl get providers. If providers appear, continue.
If up project run --local exits non-zero AND kubectl get providers returns
No resources found, provisioning failed. Run
kind delete cluster --name up-app-w-db and restart. Verify your network
allows outbound connections to xpkg.upbound.io on port 443.
Configure kubectl
In your second terminal, point kubectl at the new cluster:
kind get kubeconfig --name up-app-w-db > ~/.kube/config
This overwrites your existing ~/.kube/config. To preserve existing contexts,
use kind get kubeconfig --name up-app-w-db > ~/.kube/config-upbound and merge:
KUBECONFIG=~/.kube/config:~/.kube/config-upbound kubectl config view --flatten > ~/.kube/config.merged && mv ~/.kube/config.merged ~/.kube/config
Verify the connection:
kubectl get nodes
Apply AWS credentials
-
Create the
demonamespace:kubectl create namespace demo -
Create a secret with your AWS credentials:
kubectl create secret generic aws-secret \
-n demo \
--from-literal=creds="$(printf '[default]\naws_access_key_id = %s\naws_secret_access_key = %s\n' \
"$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY")"
Verify the setup
Check that providers are installed and healthy:
kubectl get providers
All four providers should show HEALTHY: True. Keep running this until they do
before continuing.
If this returns No resources found, up project run --local did not
complete successfully. Delete the cluster with
kind delete cluster --name up-app-w-db and restart.
Check that the composition function is healthy:
kubectl get functions
The KCL function should show HEALTHY: True.
If this returns No resources found, the KCL function was not built or
deployed. Check the up project run terminal and restart.
Apply the Compositions
Get the exact function name assigned by up project run:
FUNC_NAME=$(kubectl get functions --no-headers | grep -v 'crossplane-contrib' | awk '{print $1}')
echo $FUNC_NAME
Apply both Compositions using that name:
cat > apis/appwdb/composition.yaml <<EOF
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
labels:
provider: aws
type: app-w-db
name: appwdbs.demo.upbound.io
spec:
compositeTypeRef:
apiVersion: demo.upbound.io/v1alpha1
kind: AppWDB
mode: Pipeline
pipeline:
- step: compose-resources
functionRef:
name: \${FUNC_NAME}
- step: automatically-detect-ready-composed-resources
functionRef:
name: crossplane-contrib-function-auto-ready
EOF
cat > apis/appwdbsecure/composition.yaml <<EOF
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
labels:
provider: aws
type: app-w-db-secure
name: appwdbsecures.demo.upbound.io
spec:
compositeTypeRef:
apiVersion: demo.upbound.io/v1alpha1
kind: AppWDBSecure
mode: Pipeline
pipeline:
- step: compose-resources
functionRef:
name: \${FUNC_NAME}
- step: automatically-detect-ready-composed-resources
functionRef:
name: crossplane-contrib-function-auto-ready
EOF
kubectl apply -f apis/appwdb/composition.yaml
kubectl apply -f apis/appwdbsecure/composition.yaml
Verify the APIs are established:
kubectl get xrds
Both XRDs should show ESTABLISHED: True before continuing.
If this returns No resources found, stop here. Return to the
up project run terminal to diagnose the failure.
Apply the ProviderConfig
Apply this only after all providers are healthy:
kubectl apply -f setup/config/
AWS resource provisioning — especially RDS — takes 5–8 minutes. Each section of this tutorial is structured so you can keep reading while AWS works.
Access the UXP console
The UXP console provides a visual interface for browsing composite resources, viewing resource relationship graphs, and checking sync status.
-
Enable the web UI:
up uxp web-ui enable -
In a new terminal, port-forward to the service:
kubectl port-forward -n crossplane-system svc/webui 8080:80 -
Open
http://localhost:8080in your browser.
The console shows every composite resource, the tree of composed resources it
manages, and their sync status. You'll use it throughout this tutorial to
complement kubectl output.
Deploy an app with a database
The end-user interface for this platform is a 10-line manifest. A developer fills in three fields: replica count, database size, and AWS region. The VPC, subnets, IAM role, and RDS configuration are handled by the platform.
-
Apply the example manifest:
kubectl apply -f examples/appwdb/example.yaml -
Check the composite resource status:
kubectl get appwdb demo-01 -n demo -
Verify the
Deploymentcame up:kubectl get pods -n demo
Those 10 lines create:
- VPC + 3 subnets (eu-central-1a, b, c)
- RDS subnet group + PostgreSQL instance (gp3 storage)
- IAM role
- Kubernetes
Deploymentscaled toreplicas: 2
The platform end user never sees any of that. They requested AppWDB. The
platform decided what that means.
Open the AWS Console and set your region to eu-central-1. Check:
- IAM → Roles — look for
demo-01-role - VPC → Your VPCs — look for
demo-01-vpc - RDS → Databases — watch for
demo-01-db(~5–8 minutes)
In the UXP console, click into demo-01 and open the relationship view to
see all composed resources and their sync status.
Explore the composition
Open apis/appwdb/definition.yaml.
This is the XRD — the API your end users interact with. The dbSize field is
an enum, not a free-text field. Users can't request a size the platform doesn't
support.
Open apis/appwdb/composition.yaml.
This is the Composition — the mapping from those 10 lines to all the AWS resources. It calls the KCL function you created. You can also write Composition functions in Go, Python, or Go Templating, and mix languages within a single pipeline.
Open functions/compose-resources/main.k.
This is the logic layer. It reads dbSize and replicas from the composite
resource and outputs every managed resource Crossplane creates. The platform
team owns and maintains this file — end users never touch it.
Explore the control plane
A control plane is software that continuously watches desired state and reconciles actual state to match it — not once, but always. Crossplane turns a Kubernetes cluster into a control plane for all infrastructure and applications.
Composite Resources are the custom APIs your platform exposes. The file you
applied in examples/appwdb/example.yaml is a Composite Resource. Instead of
giving end users raw AWS access, the platform team defines higher-level
abstractions like AppWDB — and end users request those.
Providers and ProviderConfigs
Providers are how Crossplane talks to external systems like AWS. Each
provider is a Kubernetes controller that manages a specific service — EC2, RDS,
IAM, and so on. In Crossplane 2.0, the Kubernetes Deployment for your app is
composed natively — no separate Kubernetes provider needed.
ProviderConfigs tell providers how to authenticate. This demo uses a
Secret-based ProviderConfig, but each provider supports multiple
authentication methods:
| Provider | Authentication methods |
|---|---|
| AWS | OIDC (Upbound), access keys, WebIdentity, IRSA |
| Azure | OIDC (Upbound), service principal, managed identity |
| GCP | OIDC (Upbound), service account keys, workload identity |
| Helm | Injected identity with cloud provider credentials |
More details in provider authentication.
-
Confirm all four providers are healthy:
kubectl get providersAll four should show
HEALTHY: True. -
Confirm the
ProviderConfigis present:kubectl get providerconfigs.aws.m.upbound.io default -n demo
In the UXP console, navigate to demo-01 and open the relationship view to see
all 8 composed resources, their sync status, and how they connect.
Drift detection
Crossplane never stops watching. If someone changes a resource directly in AWS, Crossplane detects the difference between desired state and actual state and corrects it. This is drift detection.
Trigger drift
-
Verify the VPC is synced:
kubectl get vpcs.ec2.aws.m.upbound.io demo-01-vpc -n demoWait until
SYNCED: True. -
In the AWS Console, go to VPC → Your VPCs and find
demo-01-vpc. -
Click the Name tag and change it to something else — for example,
demo-01-vpc-hacked. Refresh to confirm the change took effect. -
Tell Crossplane to reconcile immediately instead of waiting for the next loop:
kubectl annotate vpcs.ec2.aws.m.upbound.io demo-01-vpc -n demo \
reconcile.crossplane.io/trigger="$(date)" \
--overwrite -
Watch the sync status:
kubectl get vpcs.ec2.aws.m.upbound.io demo-01-vpc -n demo -w \
-o custom-columns='NAME:.metadata.name,SYNCED:.status.conditions[?(@.type=="Synced")].reason' -
Switch to the AWS Console and watch the Name tag snap back to
demo-01-vpc.
The control plane detected the drift and corrected it — not at the next CI run, right now.
Verify recovery
kubectl get appwdb demo-01 -n demo
SYNCED: True confirms the control plane corrected the drift.
Add policy enforcement
Kyverno is a policy engine that intercepts Kubernetes admission requests before they're accepted. A policy violation is blocked before Crossplane runs — nothing reaches AWS.
Install Kyverno
-
Create the Kyverno add-on manifest:
mkdir -p w-kyverno
cat > w-kyverno/addon-kyverno.yaml <<'EOF'
apiVersion: pkg.upbound.io/v1beta1
kind: AddOn
metadata:
name: upbound-addon-kyverno
spec:
package: xpkg.upbound.io/upbound/addon-kyverno:3.7.0
EOF -
Apply it:
kubectl apply -f w-kyverno/addon-kyverno.yaml -
In the UXP console, select AddOns in the left navigation — you should see
upbound-addon-kyvernoappear and become healthy (~2 minutes). Or watch from the terminal:kubectl get addons.pkg.upbound.io upbound-addon-kyverno -wWait until
HEALTHY: Truebefore continuing. Press Ctrl+C when it does.If it stays
HEALTHY: Falseafter 5 minutes, checkkubectl describe addons.pkg.upbound.io upbound-addon-kyvernofor events. -
Create the no-privileged-containers policy:
cat > w-kyverno/policy-no-privileged.yaml <<'EOF'
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: disallow-privileged-containers
annotations:
policies.kyverno.io/title: Disallow Privileged Containers
policies.kyverno.io/category: Pod Security
policies.kyverno.io/severity: high
policies.kyverno.io/description: >-
Privileged containers have unrestricted access to the host system.
This policy blocks any AppWDBSecure request with securityContext.privileged: true
before Crossplane composes any resources — nothing reaches AWS.
spec:
validationFailureAction: Enforce
background: false
rules:
- name: no-privileged-platform-api
match:
any:
- resources:
kinds:
- AppWDBSecure
validate:
message: "Privileged containers are not allowed on this platform. Remove securityContext.privileged: true from your request."
pattern:
spec:
parameters:
=(securityContext):
=(privileged): "false"
- name: no-privileged-deployment
match:
any:
- resources:
kinds:
- Deployment
validate:
message: "Privileged containers are not allowed on this platform. Remove securityContext.privileged: true from your request."
pattern:
spec:
template:
spec:
containers:
- =(securityContext):
=(privileged): "false"
EOF -
Apply the policy:
kubectl apply -f w-kyverno/policy-no-privileged.yamlYou may see this warning:
Warning: the kind defined in the all match resource is invalid: unable to convert GVK to GVR for kinds AppWDBSecureThis is expected if the XRDs were recently established and doesn't prevent the policy from enforcing once the CRD is ready.
-
Verify the policy is active:
kubectl get clusterpolicy disallow-privileged-containersREADY: Truemeans the policy is enforcing.
Block a privileged request
Kyverno can only evaluate requests for resource types whose CRDs are installed.
If you see no matches for kind "AppWDBSecure", the XRD is not installed.
Confirm kubectl get xrds shows both XRDs as ESTABLISHED: True.
-
Try to apply a request with
privileged: true:kubectl apply -f examples/appwdbsecure/example-1.yamlThe request is blocked immediately. The error references
disallow-privileged-containers. Nothing was created — Kyverno stopped it before Crossplane even saw the request.demo-01— deployed before Kyverno was installed — has a running RDS instance right now. This one didn't even start.
Apply a compliant request
-
Apply the compliant version (
privileged: false):kubectl apply -f examples/appwdbsecure/example-2.yamlThe request passes the policy check and starts provisioning (~10 minutes).
-
Watch the status:
kubectl get appwdbsecure -n demo -w
Change it live
To change infrastructure, update the desired state. Crossplane figures out what needs to change and does it — the same interface for a human, a GitOps pipeline, or an AI agent.
Option A: Scale the database
-
Apply the change:
kubectl apply -f examples/appwdb/variant-bigger-db.yaml -
DESIREDupdates immediately;ACTUALupdates once AWS finishes (~5 minutes):kubectl get instances.rds.aws.m.upbound.io demo-01-db -n demo -w \
-o custom-columns='NAME:.metadata.name,DESIRED:.spec.forProvider.instanceClass,ACTUAL:.status.atProvider.instanceClass,SYNCED:.status.conditions[?(@.type=="Synced")].reason' -
In the AWS Console, check the Status and Size columns for
demo-01-db. -
Confirm the change:
kubectl get appwdb demo-01 -n demo
Option B: Scale replicas
-
Apply the change:
kubectl apply -f examples/appwdb/variant-more-replicas.yaml -
Watch the
Deploymentscale (~30 seconds):kubectl get deployment demo-01 -n demo -w \
-o custom-columns='NAME:.metadata.name,DESIRED:.spec.replicas,READY:.status.readyReplicas' -
Confirm the change:
kubectl get appwdb demo-01 -n demo
In the UXP console, navigate to demo-01 to see the full resource tree with
your updated values.
Clean up
Delete the composite resources. Crossplane deletes all composed AWS resources before removing the composite resource.
kubectl delete appwdbsecure kyverno-demo-01 -n demo
kubectl delete appwdb demo-01 -n demo
RDS deletion takes 5–10 minutes. Wait until both are fully removed:
kubectl get appwdb -n demo -w
kubectl get appwdbsecure -n demo -w
Once both are gone, stop up project run with Ctrl+C, then delete the cluster:
kind delete cluster --name up-app-w-db
Next steps
In this tutorial, you:
- Created a Crossplane project with XRDs, Compositions, and a KCL function
- Deployed a composite resource that created a VPC, subnets, IAM role, RDS
instance, and Kubernetes
Deploymentfrom a 10-line manifest - Explored the providers and ProviderConfigs that connected your platform to AWS
- Watched Crossplane detect and correct an out-of-band change to a VPC tag
- Blocked a privileged container request with Kyverno before it reached the cluster
- Updated live infrastructure by changing desired state
Continue with:
- Composite Resource Definitions — design your own platform APIs
- Composition functions — write the logic that maps user requests to resources
- Provider authentication — connect providers to your own cloud account
- Upbound Marketplace — providers and add-ons for AWS, Azure, GCP, and more