Crossplane v2 - Integrating With AWS ECR

Recently I’ve been playing a lot with Crossplane and I ran up against needing to work with images in a Private ECR Registry.

Crossplane has a built in solution to working with a private image registry using what they call ImageConfigs and a classic ImagePullSecret, you can see a breakdown of how that works in the docs here, but as anyone who has worked with private ECR will know, it is particularly allergic to using static secrets and likes to use STS. In this post I’ll take a look at my suggestions for how to integrate in two scenarios:

  • Installing Crossplane (where the installation image comes from a private ECR Registry)
  • Installing Providers and Functions from a private ECR Registry

Assumptions

As part of this article, I am going to assume that you have already created some Repositories in your private ECR Registry and managed to get some images in there, I won’t be covering how to go about that or the guide will be too long.

I will be assuming that you have the below Repositories and images created in your ECR Registry (all pulled from xpkg.crossplane.io originally):

  • crossplane/crossplane:latest
  • upbound/provider-family-aws:v2.2.0
  • upbound/provider-aws-s3:v2.1.0
  • upbound/function-patch-and-transform:v0.9.0

Installing Crossplane - Configuring NodeGroup Permissions

Despite running two different application Pods, crossplane actually only pulls a single image as part of it’s installation; crossplane/crossplane so that’s one less thing to worry about.

We can most easily ensure that the image can be transparently pulled by expanding the scope of the NodeGroup Instance Role for the Nodes Crossplane will run on to include these additional permissions:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage"
      ],
      "Resource": [
        "arn:aws:ecr:eu-west-2:1234567890123:crossplane/crossplane" //--Your image repo ARN here
      ]
    }
  ]
}

These permissions can be created as an additional IAM Policy, then bound to your existing NodeGroup Instance Role (if you have multiple NG Instance Roles you can assign this as you see fit). To determine your current NodeGroup Instance Role configuration, use the AWS CLI:

aws eks list-nodegroups --cluster-name tfc-cluster1
{
    "nodegroups": [
        "tooling",
        "regular_workloads",
        "secure_workloads"
    ]
}

aws eks describe-nodegroup --cluster-name tfc-cluster1 --nodegroup-name regular_workloads | grep nodeRole
        "nodeRole": "arn:aws:iam::1234567890123:role/regular_workloads", #--Your NG Instance Role ARN

Installing Crossplane - Service Account Considerations

With this policy in place, the Crossplane image can be pulled, but additional configuration is needed in advance to ensure our Providers and Functions can be installed. Their installation is handled via the Crossplane Service Account which needs to be bound to an existing IAM role with permissions to pull images.

Your IAM role must have at least the permissions shown below:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage"
      ],
      "Resource": [
        "arn:aws:ecr:eu-west-2:1234567890123:upbound/provider-*", //--Wildcarding is to allow pulling
        "arn:aws:ecr:eu-west-2:1234567890123:upbound/function-*"  //--all Providers and functions
      ]
    }
  ]
}

Once this role exists, Crossplane can be installed with the below Helm Values:

#--helm_values.yaml

image:
  repository: 1234567890123.dkr.ecr.eu-west-2.amazonaws.com/crossplane/crossplane
  tag: latest
serviceAccount:
  customAnnotations:
    eks.amazonaws.com/role-arn: $PROVIDER_AND_FUNCTION_PULL_IAM_ROLE_ARN

Install with

helm install crossplane -n crossplane-system crossplane-stable/crossplane -f helm_values.yaml

Installing Providers and Functions

Once Crossplane is stable, Providers and Functions can be installed, the below image highlights what we’re aiming for:

There is an important gotcha when working with a private registry around provider dependencies, these are not perfectly handled and can lead to some very confusing errors and behavior

To highlight how this will behave in reality, if we were to apply the below on a fresh Crossplane deployment:

apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-s3
spec:
  package: 1234567890123.dkr.ecr.eu-west-2.amazonaws.com/upbound/provider-aws-s3:v2.2.0

We will see:

kubectl get provider
# NAME                          INSTALLED   HEALTHY   PACKAGE                                                                       AGE
# upbound-provider-family-aws   False       False     xpkg.upbound.io/upbound/upbound-provider-family-aws:v2.2.0                    8m
# provider-aws-s3               True        True      1234567890123.dkr.ecr.eu-west-2.amazonaws.com/upbound/provider-aws-s3:v2.2.0  9m

The dependency upbound-provider-family-aws contains essential dependencies for the s3 Provider to function, as such it has attempted to install automatically. When using a private registry however, this installation will fail, if I remember correctly this is due to a dependency resolution error that occurs when the dependencies have a URI that doesn’t match the Provider (don’t quote me on that, I can’t find my source)!

It is worth pointing out that even if your Provider has somehow managed to install and shows as healthy, but the dependencies haven’t, failures are still going to occur somewhere. These often manifest in in the form of ServiceAccount RBAC errors relating to ProviderConfigs and ProviderConfigUsages. This stems from the fact that when a Provider’s dependencies fail to install, a complete set of CRDs underpinning the Provider have also not been installed. These CRDs are supposed to be part of a ClusterRole that has failed to materialise correctly.

These problems can be pretty confusing, hard to pin down and are really best avoided all together.

To steer clear of this it is best to install both the Providers AND their dependencies manually:

#---Install Deps
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-family-aws
spec:
  package: 1234567890123.dkr.ecr.eu-west-2.amazonaws.com/upbound/provider-family-aws:v2.2.0

#---Install Provider, skipping dependency resolution
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-s3
spec:
  package: 1234567890123.dkr.ecr.eu-west-2.amazonaws.com/upbound/provider-aws-s3:v2.2.0
  skipDependencyResolution: true #--Skip dependency resolution. We have installed manually
                                #--if unset, deps will still be installed from xpkg.upbound.io

We can verify all is well with:

kubectl get provider
# NAME                          INSTALLED   HEALTHY   PACKAGE                                                                       AGE
# upbound-provider-family-aws   True        True      xpkg.upbound.io/upbound/upbound-provider-family-aws:v2.2.0                    12m
# provider-aws-s3               True        True      1234567890123.dkr.ecr.eu-west-2.amazonaws.com/upbound/provider-aws-s3:v2.2.0  11m
Written on November 6, 2025