Owner References and Secondary Watches

October 7, 2025

Owner References and Secondary Watches
Owner References and Secondary Watches

Your controller creates a Deployment. The Deployment changes. How does your controller find out? Owner references and secondary watches.

Estimated Reading Time : 8m

The problem

A controller that manages a custom resource often creates child resources — Deployments, Services, ConfigMaps. When those child resources change (e.g., a pod managed by the Deployment crashes), your controller needs to reconcile the parent.

But your controller only watches the parent type. It doesn’t know about changes to child resources unless you tell it to.

Owner references

An owner reference is a metadata field on a Kubernetes resource that points to its parent. When the parent is deleted, Kubernetes garbage-collects all resources that reference it as an owner.

Setting an owner reference:

func (r *BackupReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    var backup v1alpha1.Backup
    if err := r.Get(ctx, req.NamespacedName, &backup); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // Create a Job owned by the Backup
    job := &batchv1.Job{
        ObjectMeta: metav1.ObjectMeta{
            Name:      backup.Name + "-job",
            Namespace: backup.Namespace,
        },
        Spec: batchv1.JobSpec{
            // ...
        },
    }

    // Set the Backup as the owner of the Job
    if err := ctrl.SetControllerReference(&backup, job, r.Scheme()); err != nil {
        return ctrl.Result{}, err
    }

    if err := r.Create(ctx, job); err != nil {
        return ctrl.Result{}, client.IgnoreAlreadyExists(err)
    }

    return ctrl.Result{}, nil
}

ctrl.SetControllerReference sets the owner reference and marks it as the “controller” owner. This means:

  • When the Backup is deleted, the Job is garbage-collected
  • Only one controller can be the “controller” owner (prevents conflicts)
  • The owner must be in the same namespace as the owned resource

Secondary watches

Owner references handle cleanup, but they don’t tell your controller when a child resource changes. For that, you need a secondary watch with Owns:

ctrl.NewControllerManagedBy(mgr).
    For(&v1alpha1.Backup{}).
    Owns(&batchv1.Job{}).
    Complete(reconciler)

Owns sets up a watch on Jobs. When a Job changes, controller-runtime looks at the Job’s owner reference to find the parent Backup, and enqueues a reconcile for that Backup — not for the Job.

This is the key insight: the reconciler always reconciles the parent resource. Child resource changes are just triggers.

How it works together

Backup (parent)          Job (child)
    │                        │
    │─── creates ──────────▶ │
    │                        │  (owner reference points back)
    │◀── Owns() watch ──────│
    │                        │
    │  Job fails → reconcile │
    │  Backup is reconciled  │
  1. Your controller creates a Job with an owner reference pointing to the Backup
  2. Owns(&batchv1.Job{}) watches Jobs for changes
  3. When the Job completes or fails, controller-runtime finds the parent Backup via the owner reference
  4. Your reconciler runs with the Backup’s name — not the Job’s

Watching unowned resources

Sometimes you need to react to resources that aren’t owned by your custom resource. Use Watches with a custom handler:

ctrl.NewControllerManagedBy(mgr).
    For(&v1alpha1.Backup{}).
    Owns(&batchv1.Job{}).
    Watches(
        &corev1.Secret{},
        handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []ctrl.Request {
            // Find all Backups that reference this Secret
            var backups v1alpha1.BackupList
            if err := mgr.GetClient().List(ctx, &backups, client.MatchingFields{
                "spec.secretName": obj.GetName(),
            }); err != nil {
                return nil
            }

            var requests []ctrl.Request
            for _, b := range backups.Items {
                requests = append(requests, ctrl.Request{
                    NamespacedName: types.NamespacedName{
                        Name:      b.Name,
                        Namespace: b.Namespace,
                    },
                })
            }
            return requests
        }),
    ).
    Complete(reconciler)

This watches Secrets and maps each change to the Backup(s) that reference that Secret. When a database credential Secret is rotated, the Backup controller reconciles.

Multiple child types

A controller can own multiple child resource types:

ctrl.NewControllerManagedBy(mgr).
    For(&v1alpha1.MyApp{}).
    Owns(&appsv1.Deployment{}).
    Owns(&corev1.Service{}).
    Owns(&corev1.ConfigMap{}).
    Complete(reconciler)

Each type gets its own watch. Changes to any child trigger reconciliation of the parent.

Common mistakes

Forgetting to set the owner reference. If you create a child resource without an owner reference, Owns() won’t be able to map it back to the parent. The watch is there but nothing triggers.

Cross-namespace ownership. Owner references only work within the same namespace. If your parent is in namespace A and the child is in namespace B, you can’t use owner references — use Watches with a custom mapping function instead.

Updating owned resources outside the controller. If another process modifies a resource your controller owns, the controller will reconcile and potentially overwrite those changes. This is by design — the controller is the source of truth for its owned resources.