Deploy an Application from Chained RGDs with OCM and kro
You have an application to run on Kubernetes. You want to ship it the same way every time: one versioned package that carries the container image and the instructions to run it. When you move that package from a development registry to production, or into an air-gapped cluster, the image references must follow automatically. You should never hand-edit a manifest to fix a registry path.
This tutorial builds exactly that. You package an application as an OCM component, describe
how to run it with two kro ResourceGraphDefinitions (RGDs) chained
together, one creating an instance
of the other’s kind, and let the OCM controllers deliver and localize it into your cluster.
kro renders plain Kubernetes manifests directly, so there is no Helm chart and no Flux or Argo
CD in the path. We use Podinfo as a stand-in for
your application.
By the end, you will have:
- An OCM component that bundles an image and two RGDs
- A running Podinfo application, deployed and kept in sync by kro
- A localized image reference that points at your registry, set automatically
Prerequisites
Before starting, make sure you have set up your environment as described in the setup guide.
- Controller environment with the OCM Controllers and kro installed (no GitOps deployer needed)
- Custom RBAC configured so the OCM controller
can watch
ResourceGraphDefinitions. The deployers’ service account gets write access in Deliver both RGDs, kro’s own access in Grant kro access to the resources in the graphs - OCM CLI
envsubst(part ofgettext; on macOS install it withbrew install gettext)- An OCI registry you can push to, for example ghcr.io
Once you’ve applied the Custom RBAC guide, confirm the OCM
controller ServiceAccount (not your admin user) can watch RGDs:
kubectl auth can-i watch resourcegraphdefinitions.kro.run \
--as=system:serviceaccount:ocm-k8s-toolkit-system:ocm-k8s-toolkit-controller-managerThis must print yes before you continue. You may also see a line like
Warning: resource 'resourcegraphdefinitions' is not namespace scoped in group 'kro.run'.
That is harmless. The answer is the yes printed below it.
Set your registry location:
export GITHUB_USERNAME=<your-github-username>
export OCM_REPO=ghcr.io/$GITHUB_USERNAME/ocm-tutorialHow it works
You deliver two RGDs inside one OCM component. The first (podinfo) defines the Podinfo
kind: the application as its own Kubernetes API. The second (system) creates a Podinfo
instance and sets its image to the one OCM localized into your registry. The OCM controllers
deliver both the same way described in Concept: Kubernetes Deployer:
a Repository and Component fetch the component, and one Resource + NamespacedDeployer pair per
RGD applies it to the cluster. You then create a single System instance, and reconciliation
converges to a running Podinfo.
Localization keeps the image correct once it lands in kro’s hands. Transfer preserves
artifact integrity, and localization adapts the reference at deploy time.
Here, that means the system RGD sets the image field of the Podinfo instance to the
rewritten reference, so the pod always pulls from the registry the component was delivered to.
The diagram below picks up once both RGDs are already applied, and shows what kro itself does with them.
Architecture diagram
(Continues from Concept: Kubernetes Deployer, which
shows the Repository → Component → Resource → NamespacedDeployer sequence that gets the RGD here.)
flowchart TB
subgraph kro[kro]
rgdPodinfo[RGD: podinfo]
crdPodinfo[CRD: Podinfo]
rgdSystem[RGD: system]
crdSystem[CRD: System]
rgdPodinfo -->|registers| crdPodinfo
rgdSystem -->|registers| crdSystem
subgraph instanceSystem[Instance: System]
k8sResImg[Resource: Image]
podinfoInstance[Podinfo instance]
k8sResImg -->|image reference, localized| podinfoInstance
end
end
crdSystem -.->|instantiated as| instanceSystem
crdPodinfo -.->|instantiated as| podinfoInstance
podinfoInstance ==>|kro-native: templated directly| workload["Workload: Deployment + Service"]
classDef reconciledBy fill:#def2e4,stroke:#4d8a4d,color:#1a1a1a
classDef k8sObject fill:#d7e4fb,stroke:#3c63c8,color:#1a1a1a
class kro,instanceSystem reconciledBy;
class rgdPodinfo,rgdSystem,crdPodinfo,crdSystem,k8sResImg,podinfoInstance,workload k8sObject;
Create the component
Work in a fresh directory:
mkdir -p kro-tutorial && cd kro-tutorialDefine the component
The component bundles three resources: the Podinfo image and the two RGD files.
cat > component-constructor.yaml << 'EOF'
components:
- name: ocm.software/ocm-k8s-toolkit/system
version: "1.0.0"
provider:
name: ocm.software
resources:
- name: image-podinfo
type: ociArtifact
version: "1.0.0"
access:
type: OCIImage/v1
imageReference: "ghcr.io/stefanprodan/podinfo:6.11.1@sha256:8fa56908408de98f24aed2a162b1bb42c0b98df7abfcc5a76a14a8be510457c5"
- name: rgd-podinfo
type: blob
version: "1.0.0"
input:
type: File/v1
path: ./rgd-podinfo.yaml
- name: rgd-system
type: blob
version: "1.0.0"
input:
type: File/v1
path: ./rgd-system.yaml
EOFWrite the app RGD
This RGD registers the Podinfo API and renders the application: a Deployment and a Service.
kro templates values from the instance schema and reconciles resources in dependency order,
which it infers from the CEL references between them. deployment and service only reference
the schema, so they are independent and reconcile in parallel.
cat > rgd-podinfo.yaml << 'EOF'
apiVersion: kro.run/v1alpha1
kind: ResourceGraphDefinition
metadata:
name: podinfo
spec:
schema:
apiVersion: v1alpha1
kind: Podinfo
spec:
image: string
message: string | default="Hello from OCM and kro"
replicas: integer | default=1 minimum=0
resources:
- id: deployment
readyWhen:
- ${deployment.status.conditions.exists(c, c.type == 'Available' && c.status == 'True')}
template:
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${schema.metadata.name}
spec:
replicas: ${schema.spec.replicas}
selector:
matchLabels:
app: ${schema.metadata.name}
template:
metadata:
labels:
app: ${schema.metadata.name}
spec:
containers:
- name: podinfo
image: ${schema.spec.image}
ports:
- containerPort: 9898
env:
- name: PODINFO_UI_MESSAGE
value: ${schema.spec.message}
- id: service
template:
apiVersion: v1
kind: Service
metadata:
name: ${schema.metadata.name}
spec:
type: ClusterIP
selector:
app: ${schema.metadata.name}
ports:
- port: 80
targetPort: 9898
EOFWrite the system RGD
This RGD reads the localized image from the OCM component and creates a Podinfo instance
with it. The resourceImage node fetches the image reference. The app node assembles the
pod image as registry/repository@digest from the three fields toOCI() exposes under
resourceImage.status.additional.oci, then passes it with your message to the Podinfo
instance. Pinning the digest (not the tag) locks in the exact image OCM localized.
cat > rgd-system.yaml << 'EOF'
apiVersion: kro.run/v1alpha1
kind: ResourceGraphDefinition
metadata:
name: system
spec:
schema:
apiVersion: v1alpha1
kind: System
spec:
message: string | default="Hello from OCM and kro"
replicas: integer | default=1 minimum=0
resources:
- id: resourceImage
readyWhen:
- ${resourceImage.status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')}
template:
apiVersion: delivery.ocm.software/v1alpha1
kind: Resource
metadata:
name: system-image-resource
spec:
componentRef:
name: system-component
resource:
byReference:
resource:
name: image-podinfo
additionalStatusFields:
oci: resource.access.toOCI()
- id: app
readyWhen:
- ${app.status.conditions.exists(c, c.type == 'Ready' && c.status == 'True')}
template:
apiVersion: kro.run/v1alpha1
kind: Podinfo
metadata:
name: podinfo
spec:
image: ${resourceImage.status.additional.oci.registry}/${resourceImage.status.additional.oci.repository}@${resourceImage.status.additional.oci.digest}
message: ${schema.spec.message}
replicas: ${schema.spec.replicas}
EOFWhy two RGDs, and when Helm is still simpler
You just wrote two RGDs instead of one. That split is deliberate:
- The app RGD (
podinfo) is the Podinfo application packaged as its own API: it registers thePodinfokind and renders the Deployment and Service you just wrote. It contains nothing OCM-specific. - The system RGD (
system) is the OCM-specific wiring. It reads the localized image from the OCM component and creates aPodinfoinstance with that image and your message.
Whether this split is worth it depends on what you’re shipping. If your application already
ships as a Helm chart, or you’re doing a one-off install of someone else’s chart, a
HelmRelease is simpler to write: see Deploy an Application from a Helm Chart with OCM and kro.
The plain-manifest RGDs pay off when you own the application: the app RGD turns it into a
first-class Kubernetes API, so operators run kubectl get podinfo, set fields with
validation, and read status like any built-in resource, all without needing to know OCM is
behind it. Keeping the system RGD separate is what lets that API stay reusable.
For one app you could fold both RGDs into one. Keeping them separate is what makes the deployment reusable and composable: the application stays generic, the OCM wiring stays in one place, and a real product just adds more app RGDs under one system RGD. The Scaling to a whole product section at the end shows how.
Build and transfer
Build the component version locally (cv is short for component version). This reads
component-constructor.yaml from the current directory and creates a transport-archive.
ocm add cvOn success it prints a summary table:
COMPONENT │ VERSION │ PROVIDER
─────────────────────────────────────┼─────────┼──────────────
ocm.software/ocm-k8s-toolkit/system │ 1.0.0 │ ocm.softwareTransfer it to your registry. An OCI uploader entry uploads the image as a standalone OCI image in
your registry that can be pulled individually, instead of leaving the component pointing back at
ghcr.io/stefanprodan; a local blob catch-all copies every other resource. See Resource
Handling: References vs. Copies
for why that distinction exists.
cat > .ocmconfig << 'EOF'
type: generic.config.ocm.software/v1
configurations:
- type: oci.uploader.transfer.config.ocm.software/v1alpha1
- type: localblob.uploader.transfer.config.ocm.software/v1alpha1
EOFNote: The CLI merges
.ocmconfigfrom the current directory with your other OCM configuration (such as$HOME/.ocmconfig), so credentials and resolvers stay in effect.
ocm transfer cv \
"transport-archive//ocm.software/ocm-k8s-toolkit/system:1.0.0" $OCM_REPOThe command streams level=INFO progress lines. It succeeded when the last line reads:
... level=INFO msg="Transferring component versions: operation finished"Confirm localization. The image reference now points at your registry, not ghcr.io/stefanprodan:
ocm get cv "$OCM_REPO//ocm.software/ocm-k8s-toolkit/system:1.0.0" -o yaml | grep imageReferenceimageReference: ghcr.io/<your-username>/ocm-tutorial/stefanprodan/podinfo:6.11.1Only the registry and path changed. The original tag stays in the reference. At deploy
time the system RGD resolves this to a digest-pinned reference (...@sha256:...), which is
what the running pod uses. You will see that digest form in the verification step below.
A freshly pushed ghcr.io package is private. To make it public, go to the packages tab in
your GitHub repository https://github.com/<your-github-username>?tab=packages, select the package
component-descriptors/ocm.software/ocm-k8s-toolkit/system, and under “Package settings”
change the visibility to public. This lets the cluster pull it without credentials.
Alternatively, if you want to keep your package private, configure credentials for the OCM Controllers and kro:
Using a private registry
Create a pull secret from a token with read:packages, in the same namespace as the bootstrap
resources:
kubectl create secret docker-registry ghcr-secret \
--namespace default \
--docker-username=$GITHUB_USERNAME \
--docker-password="$(gh auth token)" \
--docker-server=ghcr.ioThen wire it into three places:
The
Repositoryinbootstrap.yaml. Credentials propagate to the Component and Resources:spec: repositorySpec: baseUrl: $OCM_REPO type: OCIRegistry interval: 1m ocmConfig: - kind: Secret name: ghcr-secretThe
resourceImagenode inrgd-system.yaml, so the controller can read the image:additionalStatusFields: oci: resource.access.toOCI() ocmConfig: - kind: Secret name: ghcr-secretThe pod in
rgd-podinfo.yaml, so the kubelet can pull the image. Add under the podspec:spec: imagePullSecrets: - name: ghcr-secret containers: - name: podinfo
Rebuild and transfer the component after editing the RGDs.
For more details, see Configure Credentials for Controllers.
Deploy the application
Grant kro access to the resources in the graphs
kro needs a ClusterRole for the System and Podinfo instances that your two RGDs define,
and for the Deployment and Service that the podinfo RGD renders. The role for OCM resources
comes from the setup guide. See
Access Control in the kro documentation for details.
This is separate from the system-deployer service account, which only allows the NamespacedDeployers to apply
the RGDs themselves.
cat > kro-rbac.yaml << 'EOF'
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: kro:controller:systems
labels:
# kro picks up every ClusterRole with this label, see https://kro.run/docs/advanced/access-control
rbac.kro.run/aggregate-to-controller: "true"
rules:
# System instances: you create them, kro reconciles them
- apiGroups:
- kro.run
resources:
- systems
- systems/status
verbs:
- get
- list
- patch
- update
- watch
# Podinfo instances: the system RGD creates one, so kro needs all verbs
- apiGroups:
- kro.run
resources:
- podinfoes
- podinfoes/status
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
# The workload in the podinfo RGD
- apiGroups:
- apps
resources:
- deployments
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
- ""
resources:
- services
verbs:
- create
- delete
- get
- list
- patch
- update
- watch
EOF
kubectl apply -f kro-rbac.yamlDeliver both RGDs
The bootstrap resources are OCM controller objects: a Repository and Component fetch the
component from your registry; then a Resource + NamespacedDeployer pair per RGD applies it to the
cluster. The objects all live in default, so their cross-references and the verification
commands below work no matter which namespace your kubectl context points at. Each
NamespacedDeployer applies as the system-deployer service account. RGDs are cluster-scoped, so
that service account needs a ClusterRole for them.
cat > bootstrap.yaml << 'EOF'
apiVersion: delivery.ocm.software/v1alpha1
kind: Repository
metadata:
name: system-repository
namespace: default
spec:
repositorySpec:
baseUrl: $OCM_REPO
type: OCIRegistry
interval: 1m
---
apiVersion: delivery.ocm.software/v1alpha1
kind: Component
metadata:
name: system-component
namespace: default
spec:
component: ocm.software/ocm-k8s-toolkit/system
repositoryRef:
name: system-repository
semver: 1.0.0
interval: 1m
---
apiVersion: delivery.ocm.software/v1alpha1
kind: Resource
metadata:
name: system-rgd-podinfo
namespace: default
spec:
componentRef:
name: system-component
resource:
byReference:
resource:
name: rgd-podinfo
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: system-deployer
namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: system-deployer
rules:
- apiGroups: ["kro.run"]
resources: ["resourcegraphdefinitions"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: system-deployer
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system-deployer
subjects:
- kind: ServiceAccount
name: system-deployer
namespace: default
---
apiVersion: delivery.ocm.software/v1alpha1
kind: NamespacedDeployer
metadata:
name: system-deployer-podinfo
namespace: default
spec:
resourceRef:
name: system-rgd-podinfo
serviceAccountName: system-deployer
---
apiVersion: delivery.ocm.software/v1alpha1
kind: Resource
metadata:
name: system-rgd-system
namespace: default
spec:
componentRef:
name: system-component
resource:
byReference:
resource:
name: rgd-system
---
apiVersion: delivery.ocm.software/v1alpha1
kind: NamespacedDeployer
metadata:
name: system-deployer-system
namespace: default
spec:
resourceRef:
name: system-rgd-system
serviceAccountName: system-deployer
EOFApply them. envsubst fills in $OCM_REPO:
envsubst < bootstrap.yaml | kubectl apply -f -Wait for both RGDs to reach Active. This usually takes a few seconds:
kubectl get rgdNAME APIVERSION KIND STATE READY AGE
podinfo v1alpha1 Podinfo Active True 12s
system v1alpha1 System Active True 11sThe podinfo RGD registers the Podinfo CRD, and the system RGD registers the System
CRD. The system RGD references the Podinfo kind. If it becomes Active before the podinfo
RGD, it recovers on its own once the Podinfo CRD is registered, so the order does not matter.
Create the System instance
Create an instance of the System CRD:
cat > instance.yaml << 'EOF'
apiVersion: kro.run/v1alpha1
kind: System
metadata:
name: system
namespace: default
spec:
message: "Hello from OCM and kro"
replicas: 1
EOF
kubectl apply -f instance.yamlWait for it to become Active. This usually takes around 30 seconds, mostly the image pull:
kubectl get system,podinfo -n defaultNAME STATE READY AGE
system.kro.run/system ACTIVE True 30s
NAME STATE READY AGE
podinfo.kro.run/podinfo ACTIVE True 28sVerify
Check that the pod runs the localized image. The reference points at your registry and pins a digest:
kubectl get pods -n default -l app=podinfo -o jsonpath='{.items[0].spec.containers[0].image}'ghcr.io/<your-username>/ocm-tutorial/stefanprodan/podinfo@sha256:8fa56908408de98f24aed2a162b1bb42c0b98df7abfcc5a76a14a8be510457c5Check that your message reached the container:
kubectl get pods -n default -l app=podinfo \
-o jsonpath='{.items[0].spec.containers[0].env[?(@.name=="PODINFO_UI_MESSAGE")].value}'Hello from OCM and kroCall the application and see the message in its response:
kubectl port-forward -n default svc/podinfo 9898:80 &
sleep 2
curl -s http://localhost:9898/ | grep message
kill %1 # stops the port-forward (assumes no other background jobs)The sleep gives the port-forward a moment to open. Without it, curl may run before the
tunnel is ready and return nothing.
"message": "Hello from OCM and kro",Change the message or replica count by editing instance.yaml and re-applying. kro
reconciles the running workload.
Clean up
Delete the instance before the RGDs. Deleting an RGD while its instances still exist can
strand them on a finalizer. Then delete the NamespacedDeployers before their service account:
they prune the RGDs with its permissions.
kubectl delete system system -n default
kubectl delete namespaceddeployer system-deployer-podinfo system-deployer-system -n default --wait
envsubst < bootstrap.yaml | kubectl delete -f - --ignore-not-found
kubectl delete -f kro-rbac.yamlBy default kro keeps the Podinfo and System CRDs after the RGDs are gone. Installing kro
with --set config.allowCRDDeletion=true changes that: deleting the RGD then deletes its CRD
as well. Leftover CRDs are harmless: they carry no instances and cost nothing to leave.
Deleting a CRD removes every instance of that kind cluster-wide, so unless you know this is the
only thing on the cluster using those kinds, leave them. They disappear along with everything
else if you tear down the whole cluster, for example with kind delete cluster.
Troubleshooting
RGD stuck Inactive: the system RGD cannot compile until the Podinfo CRD exists. Check
that the podinfo RGD is Active (kubectl get rgd). It converges automatically.
If an RGD stays Inactive, check the reason:
kubectl get rgd podinfo -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}'A message like cache sync timeout for kro.run/v1alpha1, Resource=podinfoes often means that kro
lacks access to the instances of that kind. Check that you applied kro-rbac.yaml.
Any other message means the RGD does not compile. It names the resource and the problem, for
example failed to compile template expression ... undefined field for a typo in a CEL
expression, or cannot resolve group version kind ...: schema not found for a kind whose CRD is
not installed. Fix the RGD and apply it again.
Instance stuck: an RGD can be Active while its instance is not. The instance’s conditions
name the resource that is waiting or failing:
kubectl describe system system -n default
kubectl describe podinfo podinfo -n defaultA message like waiting for node "resourceImage": readyWhen condition evaluated to false means
that resource is not ready yet, and the resources after it wait for it. In this tutorial app
waits for resourceImage, so check that Resource first.
If the message says forbidden, kro lacks a permission. The kro logs name it:
kubectl logs -n kro-system deployment/kro | grep forbiddenAdd that kind to kro-rbac.yaml from
Grant kro access to the resources in the graphs
and apply it again.
Resource not Ready / image pull errors: the cluster cannot read the package. Make the
ghcr.io package public, or follow the private-registry steps above.
Nothing happens after applying bootstrap: check the controller logs.
kubectl logs -n ocm-k8s-toolkit-system deploy/ocm-k8s-toolkit-controller-manager --tail=40RBAC denied: a forbidden on a NamespacedDeployer means the system-deployer service account lacks a
write verb. A watch error in the controller logs means the controller lacks read access: re-check the impersonation
command in Prerequisites and apply the Custom RBAC guide.
What you learned
- Packaged an application as an OCM component with an image and two RGDs
- Used a system RGD to instantiate an app RGD with the localized image
- Delivered both RGDs with the OCM controllers, no Helm or GitOps tooling
- Confirmed the workload runs the localized image and your message
Scaling to a whole product
One app is the small case. Real products are bigger: many apps and services, shipped and versioned together. The good news is you already have the building block.
What you wrote is an app RGD. It gives one application a typed Kubernetes API, the
Podinfo kind, with a schema and status. It knows how to run one thing and nothing else.
A real product has many of these: a database, a gateway, a handful of services.
On top sits the system RGD. Its job is composition: it creates one instance of each app
RGD and passes values between them. Ordering needs no extra configuration: kro infers
dependencies from references. You saw that in this tutorial: because app references
resourceImage.status.additional.oci.*, kro does not create the Podinfo instance until
resourceImage satisfies its readyWhen condition. Add more app RGDs and reference them the
same way, and one System instance drives the entire product.
One thing is still hand-written: the delivery plumbing in bootstrap.yaml. Today you list a
Resource + NamespacedDeployer pair for every RGD yourself. For a dozen apps that is a wall of
boilerplate, and every version bump means editing that file again.
The pattern that scales moves the plumbing into an RGD. An installer RGD templates those
Resource + NamespacedDeployer pairs itself, one per app, all sharing a single Component you pass
in by name. The installer’s schema declares both inputs:
schema:
apiVersion: v1alpha1
kind: Installer
spec:
componentRef: string # the Component that holds the app RGDs
serviceAccountName: string # needs RBAC for the app RGDs
resources:
- id: appResource
template:
apiVersion: delivery.ocm.software/v1alpha1
kind: Resource
metadata: { name: app-rgd }
spec:
componentRef:
name: ${schema.spec.componentRef} # one component, passed in
resource:
byReference:
resource: { name: rgd-podinfo }
- id: appDeployer
template:
apiVersion: delivery.ocm.software/v1alpha1
kind: NamespacedDeployer
metadata: { name: app-rgd }
spec:
resourceRef:
name: ${appResource.metadata.name}
serviceAccountName: ${schema.spec.serviceAccountName}
readyWhen:
- ${appDeployer.status.conditions.exists(c, c.type == "Ready" && c.status == "True")}An Installer instance supplies them:
apiVersion: kro.run/v1alpha1
kind: Installer
metadata:
name: system
namespace: default
spec:
componentRef: system-component
serviceAccountName: system-deployerNow bootstrap.yaml shrinks to almost nothing. It delivers the installer RGD once, and the
running instance takes over its own delivery. Bump the component version and the whole graph
reconciles. No more editing bootstrap files by hand.
In this pattern kro creates the Deployer objects itself, so it also needs a labeled
ClusterRole for deployers.delivery.ocm.software. See
RBAC for CRDs kro creates at runtime.
This is not a new tool. It is the same delivery.ocm.software/Resource you already used for
the localized image, now applied to delivery itself. There is no layering limit: an RGD can
create other RGDs, which is exactly what the installer’s Resource + NamespacedDeployer pairs do for
the app RGDs. The one thing it cannot do is apply itself: something has to deliver the
installer RGD once, and that stays in bootstrap.yaml.
Next steps
- Deploy an Application from a Helm Chart with OCM and kro compares this with the Helm approach
- kro Resource Graph Definitions for composing more applications
- Configure Credentials for Controllers for private registries