For years I used Keel to automatically update container images in my Kubernetes clusters. It worked, but as I moved to GitOps with ArgoCD, Keel’s push-based approach became a liability. I migrated to Renovate for PR-based image updates, and it’s been a significant improvement.

The Problem with Keel

Keel watches for new container images and updates deployments directly in the cluster. You can configure it via annotations:

metadata:
  annotations:
    keel.sh/policy: major
    keel.sh/trigger: poll

When a new image appears, Keel modifies the deployment in-place.

Why This Broke Down

  1. GitOps drift - Keel updates the cluster, but not git. ArgoCD sees drift and wants to revert to what’s in git. You end up fighting your own tools.

  2. No review process - Updates happen automatically. A bad image goes live immediately. Rolling back means finding the old tag and manually updating.

  3. Notification-only visibility - Keel can send Slack notifications, but there’s no audit trail, no PR history, no way to see what changed when.

  4. Limited version control - Keel’s policies (major/minor/patch) are annotation-based and hard to customize per-image.

Renovate: PR-Based Updates

Renovate takes a different approach. It scans your repository, finds container images, checks for updates, and opens pull requests. The update only happens when you merge the PR.

This fits GitOps perfectly - git remains the source of truth.

My Renovate Setup

Self-Hosted CronJob

I run Renovate as a Kubernetes CronJob rather than using the hosted service:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: renovate
  namespace: renovate
spec:
  schedule: "0 */4 * * *"  # Every 4 hours
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: renovate
              image: ghcr.io/renovatebot/renovate:42.19.5
              env:
                - name: RENOVATE_CONFIG_FILE
                  value: /config/renovate.json
                - name: RENOVATE_TOKEN
                  valueFrom:
                    secretKeyRef:
                      name: renovate-env
                      key: RENOVATE_TOKEN
              volumeMounts:
                - name: renovate-config
                  mountPath: /config
          volumes:
            - name: renovate-config
              configMap:
                name: renovate-config

Why self-hosted:

  • Full control over scan frequency
  • No rate limits from Renovate’s hosted service
  • Works with private registries
  • Runs inside my cluster, close to my git server

Configuration

The repository-level renovate.json controls what gets updated:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended"],
  "enabledManagers": ["helm-values", "kustomize", "kubernetes"],
  "kubernetes": {
    "managerFilePatterns": [
      "/(^|/)base/.+/(?:[^/]*deployment)\\.ya?ml$/"
    ]
  },
  "prConcurrentLimit": 20,
  "prHourlyLimit": 0,
  "packageRules": [
    // ... rules
  ]
}

The managerFilePatterns is crucial - it tells Renovate to only look at deployment files in my base/ directory. This prevents it from trying to update the same image in multiple overlay locations.

Package Rules: The Power Feature

This is where Renovate really shines. You can define complex rules for how different images should be handled.

Pinning Major Versions

Some apps I want to stay on a specific major version until I’m ready to upgrade:

{
  "description": "Keep linuxserver/lidarr on 2.x",
  "matchDatasources": ["docker"],
  "matchPackageNames": ["lscr.io/linuxserver/lidarr"],
  "allowedVersions": "<3.0.0"
}

Automerging Safe Updates

Minor and patch updates for stable images can merge automatically:

{
  "description": "Automerge minor/patch for Linuxserver images",
  "matchDatasources": ["docker"],
  "matchPackageNames": ["lscr.io/linuxserver/*"],
  "matchUpdateTypes": ["minor", "patch"],
  "automerge": true,
  "automergeType": "pr"
}

The PR is still created (for visibility), but it merges without manual intervention.

Handling Weird Versioning

LinuxServer images sometimes use date-based tags like 2021.12.15. These confuse semver parsing:

{
  "description": "Ignore Linuxserver date-style 2021 tags",
  "matchDatasources": ["docker"],
  "matchPackageNames": [
    "lscr.io/linuxserver/tautulli",
    "lscr.io/linuxserver/overseerr"
  ],
  "allowedVersions": "!/^2021\\./"
}

Avoiding .0 Releases

Home Assistant .0 releases are often buggy. I skip them:

{
  "description": "Home Assistant: avoid .0 releases",
  "matchDatasources": ["docker"],
  "matchPackageNames": ["ghcr.io/home-assistant/home-assistant"],
  "allowedVersions": "!/\\.0$/"
}

I also disable major/minor updates entirely for Home Assistant - I want to control those manually:

{
  "description": "Home Assistant: disable major/minor updates",
  "matchPackageNames": ["ghcr.io/home-assistant/home-assistant"],
  "matchUpdateTypes": ["major", "minor"],
  "enabled": false
}

The Workflow Now

  1. Renovate scans every 4 hours
  2. PRs are created for available updates
  3. I review (or automerge handles it)
  4. Merge to master
  5. ArgoCD syncs the new image to the cluster

Everything flows through git. I can see exactly when an image was updated, who approved it, and what the previous version was.

Dependency Dashboard

Renovate creates an issue called “Dependency Dashboard” that shows:

  • Pending updates waiting for PRs
  • Open PRs awaiting merge
  • Updates blocked by version constraints
  • Errors from failed lookups

This single issue gives you visibility into your entire update backlog.

Migration Tips

1. Start with Automerge Disabled

Get comfortable with the PR flow before enabling automerge:

{
  "extends": ["config:recommended", ":automergeDisabled"]
}

2. Use Semantic Commits

Enable semantic commits for cleaner git history:

{
  "semanticCommits": "enabled"
}

PRs get titles like fix(deps): update lscr.io/linuxserver/sonarr to v4.0.2.

3. Limit Concurrent PRs Initially

Don’t flood yourself with PRs on the first run:

{
  "prConcurrentLimit": 5
}

Increase once you’ve caught up on the backlog.

4. Remove Keel Gradually

I removed Keel annotations from one app at a time, verified Renovate was creating PRs, then moved to the next. Don’t rip out Keel all at once.

Keel vs Renovate: Summary

AspectKeelRenovate
Update methodDirect cluster modificationPull requests
GitOps compatibleNo (causes drift)Yes
Review processNone (notification only)Full PR review
Version constraintsBasic annotation policiesPowerful regex rules
RollbackManualGit revert
Audit trailLogs onlyFull git history
AutomergeN/A (always auto)Optional per-package

Result

My image updates now have:

  • Visibility - PRs show exactly what’s changing
  • Control - Rules prevent unwanted major updates
  • History - Git log shows every update
  • Safety - Automerge only for trusted packages
  • Consistency - GitOps stays clean, no drift

The slight delay (PR review vs instant deploy) is worth the reliability. When something breaks, I know exactly which merge caused it and can revert cleanly.

If you’re running GitOps with ArgoCD or Flux, Renovate is the right choice for container image updates. Keel solved a problem for the pre-GitOps era, but PR-based updates are the modern approach.