Crossplane Composition Versioning
The Problem
I needed to run multiple versions of a Crossplane composition simultaneously — wdb-v1 (stable, used by production databases) alongside wdb-v2 (new features I’m testing). The official Crossplane approach uses OCI packages, but the workflow felt heavy:
- Build package with
crossplane xpkg build - Push to Harbor with version tag
- Update every consumer manifest to reference new version
- Hope the version selector actually works (spoiler: it has bugs)
I tried it. The build-push-pull dance added friction to every composition change. Testing a small tweak meant rebuilding and republishing. Running two major versions required managing multiple packages. It felt like solving a simple problem with a complex solution.
The Decision
GitOps-synced manifests with versioned compositions (-v1, -v2 suffixes) instead of OCI package distribution. Originally deployed via ArgoCD, now managed by Flux CD.
Why Not OCI Packages?
Crossplane’s version selection is more complex than it needs to be:
- No simple
version: v1.2.3field — requires labels + selectors - Known bugs with selectors not updating refs
- Managing multiple MAJOR versions is still painful
- Every version bump requires updating consumer manifests
| Factor | OCI Packages | GitOps + Versioned Manifests |
|---|---|---|
| Complexity | Build → Push → Pull dance | Direct GitOps sync |
| Version selection | Package reference updates | compositionSelector.matchLabels |
| Multiple versions | Requires multiple packages | Coexist naturally (wdb-v1, wdb-v2) |
| Testing | Complex CI setup | Symlink composition.yaml → latest |
| Registry dependency | Yes | No |
How It Works
Compositions live as versioned files in Git:
manifests/
├── composition-v1.yaml # Stable, used by production
├── composition-v2.yaml # New features, testing
└── composition.yaml # Symlink → latest (for CI tests)Flux syncs them directly to the cluster. Both versions exist simultaneously. Claims select which version they want via labels:
apiVersion: wxs.io/v1alpha1
kind: Wdb
metadata:
name: my-database
spec:
compositionSelector:
matchLabels:
wxs.io/composition-version: "v2" # Use the new versionNo rebuilding, no republishing, no registry. Change a file, push, Flux syncs.
The Result
Before (OCI packages):
- Change composition → build → push → wait for sync → test → repeat
- Version bumps required touching every consumer manifest
- Running v1 and v2 simultaneously meant managing two separate packages
After (versioned manifests):
- Change composition → push → Flux syncs → test
- Consumers choose version via label selector (no manifest changes needed)
- v1 and v2 coexist naturally in the same directory
I’ve been using this pattern for months across harbor, wdb, wapp, and wsecret compositions. Zero regrets.
Trade-offs
Accepted:
- Not using “official” Crossplane packaging approach
- Version management is manual (file naming convention)
- No automatic dependency resolution like OCI packages provide
Gained:
- Faster iteration — seconds instead of minutes
- Easier testing — symlink to latest for CI
- Simpler rollback — revert a file, not a package
- Less infrastructure — no package registry complexity