Deploy AWS Resources and Apps using Crossplane on Kubernetes
DevOps Engineering

Deploy AWS Resources and Apps using Crossplane on Kubernetes

Install Crossplane, create an S3 bucket, then deploy an app and its AWS resource as one unit.

Arthur Azrieli

18 min read

Provisioning an S3 bucket, IAM role, VPC, or RDS instance is rarely what slows AWS teams down. The friction comes from the operating model around those resources: who may request them, which encryption, networking, tagging, backup, and retention defaults are non-negotiable, how changes are reviewed, how console drift is found, and why a short-lived development environment still needs a queue of platform tickets. Crossplane tackles that problem by making Kubernetes a control plane for cloud infrastructure. Instead of managing AWS through console edits, bespoke scripts, team-owned CI jobs, and disconnected IaC repositories, platform teams publish approved infrastructure APIs, Crossplane reconciles desired state continuously, and application teams request resources through governed Kubernetes objects.

In this guide you install Crossplane, connect it to AWS, and create a real S3 bucket from a Kubernetes manifest. Then you go further and deploy an application together with its AWS resource as one unit, using a single Composition that creates both and deletes both.

‍

Who this Crossplane and AWS guide is for

  • DevOps, SRE, and platform engineers who are evaluating Crossplane as a Kubernetes-native Infrastructure as Code model for provisioning, securing, and governing AWS resources across teams, environments, and accounts.
  • Application developers who are being asked to own more of the delivery path and need a safe, repeatable way to request AWS infrastructure without receiving broad account-level permissions, copying fragile YAML from old services, or learning every low-level AWS option before they can ship.
  • Engineering managers and technical leads who want to reduce ticket-driven handoffs, standardize infrastructure delivery, and offer governed self-service while retaining visibility into cost, ownership, policy compliance, audit history, and change control.

‍

Why Crossplane matters for AWS platform teams now

Getting started with Crossplane is less straightforward than with a well-established tool like Terraform. Some documentation is not precise for every use case or provider, and even AI-generated examples do not always work as expected. This guide aims to make the process easier by showing you step by step how to install and configure Crossplane and deploy your first AWS resource.

Crossplane has also moved on. Crossplane v2 (August 2025) made composite and managed resources namespaced and replaced classic patch-and-transform Compositions with composition functions. The AWS providers are now split per service (for example provider-aws-s3). The notes below show where this matters.

‍

Why should you even use Crossplane then?

Crossplane is especially useful when application delivery and cloud infrastructure need to move together. A preview environment, for example, might include the app, an S3 bucket, a database, and IAM wiring, all created from Kubernetes manifests and cleaned up the same way. A SaaS platform can also expose a tenant environment as a governed request, so a tenant or automation workflow can create a full stack without running separate IaC plan and apply commands. That does not make Crossplane a universal replacement for Terraform, but it is strong when Kubernetes is already the interface developers use.

‍

How to use this article

  • We prepared a repository with resources to deploy everything needed.
  • We explain on each resource what it does.
  • We walk you through how to deploy Crossplane.
  • We deploy an S3 Bucket to make sure everything works.
  • We deploy an application together with its S3 bucket as one unit, then tear everything down.

‍

Prerequisites

1. Clone the repository and step into it

git clone https://github.com/MeteorOps/crossplane-aws-provider-bootstrap.git
cd crossplane-aws-provider-bootstrap

‍

2. Make sure you have the required CLIs:

  1. Install the AWS CLI & Authenticate it with your AWS Account
  2. Install the Kubectl CLI
  3. Install the Helm CLI
  4. An existing Kubernetes cluster (we’ll be using kind)

‍

Optional: Start a local kind cluster

brew install kind

kind runs nodes as Docker containers, so start Docker before creating the cluster.

open /Applications/Docker.app
kind create cluster
kubectl cluster-info --context kind-kind

‍

Repository Overview

Link to the Github Repository: https://github.com/MeteorOps/crossplane-aws-provider-bootstrap.git

  1. creds file
    ‍
    AWS credentials, which you fill in with your own
  2. crossplane-provider-conf file
    ‍
    Uses the creds file to create a Crossplane ProviderConfig (in a separate file because it can only be applied once the Provider is ready)
  3. crossplane-provider-bootstrap file
    ‍
    Creates the Crossplane AWS Provider, which enables creating AWS resources using Crossplane (and its dependencies):
    ServiceAccount, DeploymentRuntimeConfig, Provider, ClusterRole & ClusterRoleBindings
  4. bucket-definitions and bucket-crd files
    ‍
    These Crossplane manifests define the reusable bucket API. One manifest declares the CompositeResourceDefinition, the Kubernetes API type users will request; the other declares the Composition, the implementation that maps that API to a managed AWS S3 bucket. Think of the pair as the Crossplane equivalent of a small Terraform module: interface plus implementation.
    Note: the Composition must reference an existing CompositeResourceDefinition, so apply the definition first.
  5. bucket-example file
    ‍
    The example Kubernetes manifest we’ll apply at the end. It requests an S3 bucket through the bucket API defined above, which lets you validate the provider credentials, Composition, and end-to-end reconciliation path.
  6. k8s-provider-bootstrap, k8s-provider-conf and composite-app-* files
    ‍
    Used in the "Go further" section: they install the Crossplane Kubernetes Provider and define a composite application that deploys a Kubernetes workload together with its S3 bucket.

‍

Deploy Crossplane

1. Fill the creds file with your AWS access keys

‍Get the access keys of an AWS IAM user (not an SSO user, since SSO credentials need a session token) and fill them in the creds file.

  • NOTE: for production, avoid static keys and use IRSA or EKS Pod Identity (see the FAQ below).

‍

2. Deploy the Crossplane Helm Chart

‍Add the Helm repository from which the Crossplane Helm Charts will be fetched

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

Install Crossplane into the cluster in its own crossplane-system namespace. Keeping it isolated makes provider pods, package installs, and controller logs easier to inspect when reconciliation fails.

helm install crossplane crossplane-stable/crossplane --namespace crossplane-system --create-namespace

Note: this installs Crossplane v2, which removed the classic patch-and-transform Compositions (spec.resources) the repository uses. To follow this guide as written, add --version 1.20.13 to pin the last v1 line, or convert the Composition with crossplane beta convert pipeline-composition.

‍

3. Examine your Crossplane Deployment

  • Run the following command to get Crossplane's pods:
kubectl get pods -n crossplane-system

Then, you should see 2 pods: crossplane & crossplane-rbac-manager

__wf_reserved_inherit

‍

4. Provide Crossplane AWS access by creating a Kubernetes Secret

Insert your AWS credentials to the creds file and run the following from the same folder:

kubectl create secret generic aws-credentials -n crossplane-system --from-file=creds=./creds

‍

‍Make sure the secret was created as expected:

  • Run the following command:
kubectl get secret aws-credentials -n crossplane-system

You should see the aws-credentials secret:

__wf_reserved_inherit

‍

5. Deploy the Crossplane AWS Provider

‍Creating a Crossplane AWS Provider requires several resources: ServiceAccount, DeploymentRuntimeConfig, Provider, ClusterRole & ClusterRoleBindings, and ProviderConfig

We divided the resources creation into 2 phases:

  1. ‍crossplane-provider-bootstrap.yaml:
    ServiceAccount, DeploymentRuntimeConfig, Provider, ClusterRole & ClusterRoleBindings
  2. crossplane-provider-conf.yaml:
    ProviderConfig

We split it because the Provider installs the ProviderConfig CRD, so applying a ProviderConfig before the Provider is ready fails.

‍

Create the Provider Kubernetes resources using the bootstrap YAML file:

  • Run the following command:
kubectl apply -f crossplane-provider-bootstrap.yaml

‍

Validate the creation readiness of the Provider & wait for it to be ready:

  • Run the following command to see the AWS Provider resource:
kubectl get provider

You should see something like this:

__wf_reserved_inherit

It might take 1 to 2 minutes to become Healthy.

Note: for new setups, consider the per-service AWS family providers (for example xpkg.upbound.io/upbound/provider-aws-s3) instead of the single crossplane-contrib/provider-aws package used here. Their API groups differ, so the Composition changes too.

‍

Create the ProviderConfig resource & Validate its creation:

  • Run the following command:
kubectl apply -f crossplane-provider-conf.yaml && kubectl get providerconfig
__wf_reserved_inherit

‍

Create a S3 Bucket using Crossplane

Create the CompositeResourceDefinition to define a S3 Bucket:

  • Run the following command:
kubectl apply -f bucket-definitions.yaml
__wf_reserved_inherit

‍

Create the Composition that defines the S3 bucket implementation:

  • Run the following command:
kubectl apply -f bucket-crd.yaml
__wf_reserved_inherit

‍

Create the S3 Bucket Crossplane resource in Kubernetes:

kubectl apply -f bucket-example.yaml

bucket-example.yaml is a Claim; the Composition turns it into a Bucket managed resource.

Installing the AWS Provider also installed its CRDs.

One of those CRDs is bucket.

Now we can check if the bucket was created by running kubectl get bucket against our Kubernetes cluster

__wf_reserved_inherit

‍

Check if the S3 Bucket was created in AWS:

  • List your AWS S3 buckets and search for the newly created one:
aws s3 ls
__wf_reserved_inherit

‍

Go further: deploy an app and its AWS resource together

The S3 bucket above proves that the control plane works on its own. A real release is more than one bucket, though: the workload, its AWS dependencies, IAM boundaries, configuration, secrets, and runtime wiring all have to line up before the application is actually useful. Creating one bucket, role, queue, database, or workload is rarely the hard part. The hard part is making the whole environment repeatable, reviewable, and recoverable without turning every release into a manual handoff.

In this section you use one Composition to deploy a small Kubernetes application together with the S3 bucket it depends on, so both are created, reported on, and deleted as one unit. It reuses the same MeteorOps/crossplane-aws-provider-bootstrap repository, AWS provider, and credentials you set up above.

‍

What you will deploy: a Kubernetes application environment with Crossplane-managed AWS dependencies

This is where Crossplane becomes more than "cloud resources written as YAML": it becomes part of the platform contract. Developers want short-lived environments without tickets, but platform teams still need guardrails for IAM, secrets, naming, cost, drift, deletion behavior, and support ownership. Crossplane helps by exposing external dependencies through the Kubernetes API, close to the workloads that consume them. That shared surface has a sharp edge: "accepted by Kubernetes" is not the same as "created correctly in AWS" or "safe for the application to use."

Treat the repository as an executable example, not a production platform blueprint to copy line for line. Its main value is that it makes an important boundary visible: platform-owned AWS infrastructure on one side, application runtime configuration on the other. Before adapting that boundary for real teams, decide on AWS regions, deterministic resource names, mandatory tags, IAM scope, namespace isolation, connection secrets, deletion behavior, drift handling, and promotion between environments, and decide which fields developers may set directly and which the platform standardizes.

What should you expect from this walkthrough?

By the end of it you'll understand:

  1. How Crossplane can be used for full environment deployment
  2. How to deploy a sample app with AWS resources

What not to expect?

We’ll keep the example intentionally small. The goal is not to build a complete internal developer platform in one pass; it is to make the handoff visible. The application expresses a dependency, Crossplane reconciles the AWS resource, and Kubernetes runs the workload after that dependency is ready enough to use. Once that path is predictable, the production layers are easier to add without hiding the basics: policy, external secret delivery, environment promotion, observability, and CI/CD integration.

This is not a full guide to Helm providers or chart lifecycle management in Crossplane. The narrower goal is to make the boundary explicit before Helm releases and Crossplane-managed AWS resources share the same delivery workflow: what creates infrastructure, what configures the workload, and what signal tells the pipeline or operator that the environment is ready.

‍

Before you start

Kubernetes cannot be a black box here. You should be comfortable inspecting Deployments, Pods, Secrets, CRDs, Events, finalizers, and controller status conditions with kubectl. Crossplane adds another set of controllers; it does not replace the ones that schedule pods, mount secrets, or roll out workloads.

Make sure the setup from the previous sections still works: the AWS provider is Healthy, the ProviderConfig name in the manifests matches the object in the cluster, and the IAM identity behind the credentials can create, observe, update, and delete the AWS resources in your region. Valid credentials are not the same as sufficient authorization: Kubernetes may accept every manifest while the provider keeps retrying because AWS returns AccessDenied, quota, tagging, region, validation, or immutable-field errors. A missing bucket or a blocked managed resource can easily masquerade as a bad application rollout, so when progress stops, debug in this order: status conditions, then Events, then the provider logs, and only then the workload (pods, services, configuration, application behavior).

‍

Why Crossplane for the Full Environment Use-Case?

When you want to deploy a full environment, it usually involves 3 layers:

  1. Infrastructure: Resources the application needs to run well
  2. Application: The programs built by the company to serve users
  3. Data: The data the application uses

But you already know that.

For years, teams treated infrastructure and application delivery as two separate phases. Crossplane challenges that habit.

The traditional sequence was simple: provision the infrastructure first, then deploy the application on top.
Crossplane changes that boundary by letting a Kubernetes-facing resource request the cloud dependency and have a controller reconcile it with the same status-driven model you use for workloads.

Pull-Request Environments are also easier

By creating a namespace with all of the apps and the AWS resources required with Crossplane, the use-case of creating a full environment per Pull-Request as part of the CI becomes much easier.

That's a nice benefit of such setup for companies utilizing the feature-branch or Gitflow approaches.

‍

A Traditional Full Env Example

In many teams, deploying a full environment has meant coordinating several separate steps and hoping each handoff preserves the same assumptions:

  1. Provision VPC+EKS+... using Terraform
  2. Use Terraform to bootstrap the cluster with a CD tool (e.g., ArgoCD)
  3. ArgoCD looks at a repo that deploys all apps from there
  4. An application needs a new S3 Bucket, so the developer writes Terraform code for it
  5. The application gets removed after a while (but the bucket stays)
  6. Someone needs to remember that bucket was owned by that app and remove it from Terraform

‍

A Crossplane Full Env Example

To provision and deploy the environment, the flow stays familiar: start with a Kubernetes cluster, then let Crossplane reconcile the external AWS resources the application depends on.

  1. Provision VPC+EKS+... using Terraform
  2. Deploy Crossplane's prerequisites to the cluster with Terraform
  3. Package app-specific Crossplane resources inside the application Helm chart, so an application release also requests the infrastructure it depends on.
  4. Use Crossplane to install the application Helm chart and provision the shared infrastructure required by that class of applications.
  5. When an application is removed, its AWS resources are gone with it
  6. When an entire environment is terminated, its AWS resources are gone with it

‍

Choosing the Boundary: Crossplane Manages Helm, or Helm Ships Crossplane

Once Helm enters the workflow, the design question becomes concrete:

Do you package Crossplane resources inside the application chart, or do you let Crossplane install the chart after it provisions the supporting infrastructure?

The practical answer is: both, but not at the same point in the lifecycle.

Reasons for Crossplane in Helm:

  1. Create or modify app-specific resources when that app is deployed
  2. Delete app-specific resources when that app is deleted

Reasons for Helm in Crossplane:

  1. Manage dependencies between resources and applications using Crossplane
  2. Create shared resources that are not owned by a single application

‍

The Step-by-Step Guide

Deploy the simple application alongside a S3 bucket using a Crossplane Composite Application.

Note: the composite app Composition also uses the v1 patch-and-transform style (spec.resources) and a Claim, so the Crossplane v2 note in step 2 of "Deploy Crossplane" above applies here too: pin --version 1.20.13 or convert the Composition.

‍

Deploy the Crossplane Kubernetes Provider

The AWS provider you installed earlier manages AWS APIs. To let one Composition also create the application's Deployment, you add the Crossplane Kubernetes Provider (provider-kubernetes), which creates Kubernetes objects on Crossplane's behalf. Its bootstrap file creates a ServiceAccount, DeploymentRuntimeConfig, Provider, ClusterRole and ClusterRoleBinding. The example ClusterRole allows every verb on every resource, which keeps the demo simple; scope it down before using this pattern on a shared cluster.

Prepare the AWS Credentials for the Application to be able to use AWS

Run the following oneliner from the repository folder to create the Secret containing the AWS credentials in the right format as required by the Application (the application will simply run aws s3 ls to show the bucket). It reads the same creds file you filled in earlier:

       
kubectl create secret generic aws-creds \
--from-literal=aws_access_key_id=$(grep -i aws_access_key_id creds | awk -F' = ' '{print $2}') \
--from-literal=aws_secret_access_key=$(grep -i aws_secret_access_key creds | awk -F' = ' '{print $2}')

Make sure it was created as expected by fetching the secret:

__wf_reserved_inherit

Deploy the Crossplane Kubernetes Provider resources using the k8s-provider-bootstrap.yaml file

       
kubectl apply -f k8s-provider-bootstrap.yaml

Before you create anything that depends on the provider, verify that it exists and is ready. Kubernetes may accept the custom resources before the provider is healthy, but Crossplane will not reconcile them correctly until the provider is installed, configured, and reporting a ready state.

       
kubectl get providers provider-kubernetes

When the provider is healthy, the status should look similar to this:

__wf_reserved_inherit

Deploy the Crossplane Kubernetes Provider Configuration using the k8s-provider-conf.yaml file:

       
kubectl apply -f k8s-provider-conf.yaml

This is done separately as it needs to happen after the Provider resources were created.

This is where we tell the Crossplane Kubernetes Provider in which Kubernetes cluster it should operate when it's creating resources.

‍

Create a deployable unit for an application and its AWS resources with Crossplane

Here we do 3 things with 3 files:

  1. The composite-app-xrd file:
    Contains the CompositeResourceDefinition (XRD) for the K8sApplication by using the Composition of a K8s Deployment and S3 Bucket (described below)
  2. The composite-app-composition file:
    Contains the Composition definition which creates both the Kubernetes Deployment and the S3 Bucket
  3. The composite-app-example file:
    Calls the CompositeResource defined by composite-app-xrd file

This is the same interface plus implementation split as the bucket API earlier, except the implementation now composes two resources instead of one.

‍

Crossplane Resources Files Breakdown & Creation

composite-app-xrd.yaml

~ K8sApplication CompositeResourceDefinition

This defines a composite resource for a Kubernetes application, with bucketName and bucketRegion fields in the spec. Users can claim this resource as K8sApplication.
The K8sApplication CompositeResource (XRD) accepts the bucketName & bucketRegion fields and uses them to create a S3 Bucket, and to create a K8s Deployment of a mock "service" that simply runs aws s3 ls to see the bucket.

~ Deploy the CompositeResourceDefinition (XRD)
       
kubectl apply -f composite-app-xrd.yaml

‍

composite-app-composition.yaml

Defines a Composition of resources that can be created by a CompositeResource.

This is where we define the Composition that creates a combo of a Kubernetes Deployment with the mock "service" that runs aws s3 ls as well as the S3 bucket; the CompositeResource simply calls this resource.

~ Deploy the Composition
       
kubectl apply -f composite-app-composition.yaml

‍

composite-app-example.yaml

Deploys the actual K8sApplication CompositeResource, passes the target AWS region and bucket name, and exposes those same values to the Kubernetes Deployment as environment variables so the workload can access the bucket Crossplane created.

As mentioned above, the CompositeResource calls the Composition which creates the resources using the Crossplane providers.

Deploy the app by running the following command:

       
kubectl apply -f composite-app-example.yaml

‍

Look at your pretty Application

Fetch the K8sApplication resource you've just created by running the below command obsessively until it's marked as Healthy:

       
kubectl get K8sApplication

If it stays not ready, run kubectl describe k8sapplication my-app-bucket and read its conditions and Events before you look at the pod.

When reconciliation finishes, inspect the managed resource status. It should be close to this:

__wf_reserved_inherit

Print the logs of the application and see it fetching the AWS S3 Bucket:

       
kubectl logs -l app=awscli
# 2024-10-17 16:00:31 my-app-bucket-nqzhx-xzjcq
# 2024-10-17 16:00:50 my-app-bucket-nqzhx-xzjcq

‍

Teardown & Cleanup

If you deployed the composite application, delete it first: removing the K8sApplication deletes both its Deployment and its S3 bucket. Then delete the S3 Bucket Crossplane resource from the first example, which also deletes that bucket in AWS (see the FAQ on deletion).

Eventually, if we used kind to spin up a local Kubernetes cluster, we’ll terminate the cluster to keep our workstation nice and clean.

‍

Delete the application and its bucket:

       
kubectl delete -f composite-app-example.yaml

Wait until kubectl get k8sapplication no longer lists it, so Crossplane can finish deleting the bucket before you remove the cluster. If you keep the cluster, also remove the application credentials with kubectl delete secret aws-creds.

‍

Delete the S3 Bucket resource:

kubectl delete -f bucket-example.yaml

‍

If used kind delete the cluster:

kind delete cluster

‍

Useful Debugging Commands

When something is stuck, work down the chain: status conditions first, then Events, then the provider logs, and only then the workload. For the composite application, start with kubectl describe k8sapplication my-app-bucket and kubectl get providers provider-kubernetes.

kubectl get provider
kubectl logs -n crossplane-system deploy/crossplane -c crossplane
kubectl logs -n crossplane-system -l pkg.crossplane.io/provider=provider-aws

Recap

To briefly recap what you did here:

  1. Installed Crossplane, connected it to AWS, and created an S3 bucket from a Kubernetes manifest through a Composition
  2. Prepared Crossplane for deploying a mix of Kubernetes and AWS resources
  3. Defined the manifests required to deploy an app built of a Deployment and a S3 Bucket
  4. Sharpened your grasp on some Crossplane concepts
  5. Discussed some use-cases for which it's useful

If this walkthrough helped, or if you want another platform engineering or cloud-native topic covered in the same practical style, send a note to michael@meteorops.com.

Disclaimer: In actual environments or production, it’s essential to fine-tune the permissions in the different manifests. Instead of using access keys and secret keys directly, consider implementing IAM Roles for Service Accounts (IRSA) to manage permissions more securely.

Frequently asked questions

Does Crossplane require a Kubernetes cluster?

Yes. Crossplane runs as controllers inside Kubernetes and stores desired state as Kubernetes objects. Any conformant cluster works, including kind, and it does not have to run on AWS. Many teams run a dedicated management cluster that provisions resources for the others.

Should I use Crossplane or Terraform?

Terraform changes infrastructure when someone runs plan and apply. Crossplane reconciles continuously, so console changes to a managed resource are reverted on the next sync. Terraform is often simpler for foundational infrastructure; Crossplane fits when developers already work in Kubernetes and need self-service.

How do I authenticate to AWS without static keys?

On EKS, use IRSA or EKS Pod Identity. The AWS family providers accept IRSA and PodIdentity as the ProviderConfig credentials source, so the provider pod gets short-lived credentials from an IAM role.

What happens to the AWS resource if I delete the Kubernetes object?

By default Crossplane deletes it too (deletionPolicy: Delete). Set deletionPolicy: Orphan to keep it in AWS. On v2 namespaced managed resources, use managementPolicies without Delete.

Should I use Crossplane, Helm, or Argo CD for the application layer?

They solve different parts of the problem and are often combined. Helm packages manifests, Argo CD or Flux syncs them from Git into the cluster, and Crossplane reconciles the external cloud resources those manifests request. A common pattern is to ship the app and its Crossplane claim through the same GitOps flow; this guide goes one step further and lets a Composition create the Deployment itself through provider-kubernetes.

How does the application get the bucket name and AWS credentials?

In this example, the Composition patches the generated bucket name and the region into the Deployment's environment variables, and the AWS keys come from a separate aws-creds Secret. For resources that have credentials, such as databases, managed resources can write connection details to a Kubernetes Secret through writeConnectionSecretToRef, which the workload then mounts. For AWS API access from the pod itself, prefer IRSA or EKS Pod Identity over static keys.