Skip to content

Renovate (image updates as pull requests)

Pinning every image to an exact version is what makes git the source of truth: the tag in the file is the version that runs. The cost is that nothing updates on its own anymore. Renovate closes that gap. It reads every compose.yaml and Dockerfile in the repo, checks the registries, and opens a pull request in Forgejo for each newer version, with the release notes where it can find them.

flowchart LR
    r["Renovate<br/>(hourly)"] -- "scan + PR" --> fj["Forgejo PR"]
    fj -- "patch/minor, daytime" --> m["automerge"]
    fj -- "major, DB, risky" --> rev["you review + merge"]
    m & rev --> main["push to main"] --> k["Komodo redeploys<br/>changed stacks"]

Why Renovate and not Komodo's own auto-update or Watchtower: those update a container behind git's back, so the repo no longer says what's running, there's no review step, and a broken release just happens at 3 am. With Renovate every update is a commit you can read, hold back or revert.


Runner

Renovate runs self-hosted as an ordinary stack on the control host. It talks to Forgejo over the internal Docker network.

hosts/control-host/renovate/compose.yaml
services:
  renovate:
    image: renovate/renovate:44.128.0
    container_name: renovate
    restart: unless-stopped
    # One run per hour. The container stays up between runs, so Renovate's cache in /tmp/renovate survives too.
    command: ["bash", "-c", "while :; do renovate; sleep 3600; done"]
    environment:
      RENOVATE_PLATFORM: forgejo
      RENOVATE_ENDPOINT: http://forgejo:3000/api/v1
      # Clone from the endpoint's host, not the clone URL Forgejo advertises (see below)
      RENOVATE_GIT_URL: endpoint
      RENOVATE_REPOSITORIES: <YOUR_USER>/homelab
      RENOVATE_GIT_AUTHOR: Renovate Bot <renovate@<YOUR_LOCAL_DOMAIN>>
      # The repo already has renovate.json, so skip the onboarding PR
      RENOVATE_ONBOARDING: "false"
      RENOVATE_PERSIST_REPO_DATA: "true"
      RENOVATE_REPOSITORY_CACHE: enabled
      LOG_LEVEL: info
      TZ: <TZ>
    # RENOVATE_TOKEN and RENOVATE_HOST_RULES come from the Stack Environment that Komodo writes to ./.env
    env_file:
      - .env
    networks:
      - proxy
    # No healthcheck: a run-then-sleep loop with no port. Failures show on the dashboard issue and in `docker logs`.

networks:
  proxy:
    external: true
.env.example (Komodo Stack Environment)
RENOVATE_TOKEN=[[RENOVATE_TOKEN]]
# Log in to Docker Hub so lookups don't hit the anonymous rate limit
RENOVATE_HOST_RULES=[{"matchHost":"docker.io","username":"<DOCKERHUB_USER>","password":"[[DOCKERHUB_TOKEN]]"}]

The hourly while loop is simpler than a cron container or a systemd timer and keeps the cache warm. Renovate's own image is in the policy below like any other, bumped once a week.

Why RENOVATE_GIT_URL: endpoint

By default Renovate clones from the URL Forgejo advertises, which is built from ROOT_URL. If that name isn't reachable from inside the Renovate container (it sits behind an SSO login, or it's an address the container can't route to), every clone fails. gitUrl = endpoint makes Renovate ignore the advertised URL and clone from the same host as RENOVATE_ENDPOINT, here http://forgejo:3000 on the shared Docker network.

Forgejo bot user

Create a dedicated renovate user (see Forgejo) with a full name and email, add it as a Write collaborator on the infra repo, and give it a token with these scopes (Renovate's Forgejo docs):

Scope Access
repository Read and Write
user Read
issue Read and Write
organization Read

Create a renovate label in the repo too: the policy tags every PR with it.


The policy: renovate.json

The policy lives at the repo root, so changing what automerges is a reviewed commit like anything else.

renovate.json
{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended"],
  "timezone": "Etc/UTC",
  "enabledManagers": ["docker-compose", "dockerfile"],
  "ignorePaths": ["hosts/workstation/**"],
  "labels": ["renovate"],
  "dependencyDashboard": true,
  "dependencyDashboardTitle": "Renovate: container update dashboard",
  "automergeType": "pr",
  "platformAutomerge": false,
  "ignoreTests": true,
  "automergeSchedule": ["* 10-19 * * *"],
  "packageRules": [
    {
      "description": "LinuxServer tags: <version>[.<build>][-<hash>][_v<lib>]-ls<revision>. An -lsNNN rebuild counts as a patch.",
      "matchPackageNames": ["/^lscr\\.io/linuxserver//"],
      "versioning": "regex:^(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)(?:\\.(?<build>\\d+))?(?:-[0-9a-f]{7,})?(?:_v[\\d.]+)?-ls(?<revision>\\d+)$"
    },
    {
      "description": "Default: patch + minor merge themselves (daytime only, see automergeSchedule).",
      "matchUpdateTypes": ["patch", "minor"],
      "automerge": true
    },
    {
      "description": "Pre-1.0 software: a 0.x minor bump is a breaking release.",
      "matchCurrentVersion": "/^v?0\\./",
      "matchUpdateTypes": ["minor"],
      "automerge": false
    },
    {
      "description": "Manual review: the password vault.",
      "matchPackageNames": ["vaultwarden/server"],
      "automerge": false
    },
    {
      "description": "Manual review: Forgejo. A bad release stops all GitOps deploys, including its own rollback.",
      "matchPackageNames": ["codeberg.org/forgejo/forgejo"],
      "automerge": false
    },
    {
      "description": "Manual review: databases.",
      "matchPackageNames": ["/(^|/)postgres$/", "/(^|/)redis$/", "/(^|/)valkey$/", "/(^|/)mongo$/"],
      "automerge": false
    },
    {
      "description": "Manual review: Home Assistant monthly releases. Patch releases automerge.",
      "matchPackageNames": ["lscr.io/linuxserver/homeassistant"],
      "matchUpdateTypes": ["minor", "major"],
      "automerge": false
    },
    {
      "description": "Manual review: the remote-access path. A bad release here locks you out while away.",
      "matchPackageNames": ["cloudflare/cloudflared", "jc21/nginx-proxy-manager"],
      "automerge": false
    },
    {
      "description": "Manual review: Komodo core + periphery, applied by hand; systemd periphery binaries are upgraded in the same window.",
      "matchPackageNames": ["ghcr.io/moghtech/komodo-core", "ghcr.io/moghtech/komodo-periphery"],
      "groupName": "komodo",
      "automerge": false
    },
    {
      "description": "Immich server and machine learning must run the same version: one PR, manual.",
      "matchPackageNames": ["ghcr.io/immich-app/immich-server", "ghcr.io/immich-app/immich-machine-learning"],
      "groupName": "immich",
      "automerge": false
    },
    {
      "description": "Renovate releases several times a day: bump it once a week.",
      "matchPackageNames": ["renovate/renovate"],
      "schedule": ["* 0-3 * * 1"]
    },
    {
      "description": "Beszel hub and agents move together.",
      "matchPackageNames": ["henrygd/beszel", "henrygd/beszel-agent"],
      "groupName": "beszel"
    },
    {
      "description": "Majors are reviewed by hand and only get a PR after you tick them on the dashboard issue.",
      "matchUpdateTypes": ["major"],
      "automerge": false,
      "dependencyDashboardApproval": true
    }
  ]
}

Set timezone to yours: automergeSchedule and schedule are read in that zone. Later rules override earlier ones for the same setting, which is why the "major" rule is last.

Validate after every edit:

docker run --rm -v "$(pwd)/renovate.json:/renovate.json:ro" renovate/renovate:44.128.0 \
  renovate-config-validator --strict /renovate.json

What merges by itself and what waits

Update What happens Why
Patch / minor of ordinary apps Automerge, 10:00–19:59 only A bad update lands while you're awake to see the ntfy alert, never in a nightly maintenance or reboot window
Any major No PR until you tick it on the dashboard issue No half-forgotten "postgres 17 → 18" PR waiting to be clicked by accident
Databases (postgres, redis, valkey, mongo) Manual Data migrations, and they're dependencies for several apps
Password vault, Forgejo, Komodo Manual Losing them blocks everything else, including the fix
Reverse proxy and tunnel (e.g. NPM, cloudflared) Manual They're the only way in from outside; a bad release locks you out while away. Delete the rule if you'd rather automerge
Home Assistant monthly (2026.9 → 2026.10) Manual, patches automerge Monthly releases regularly break integrations
Pre-1.0 minors (0.18 → 0.19) Manual In 0.x, a minor is a breaking release
Apps that must match (Immich server + ML) One grouped PR, manual Mismatched versions don't talk to each other

automergeType: pr with platformAutomerge: false means Renovate itself merges the PR on its next run inside the window, rather than asking Forgejo to merge it later. ignoreTests: true is needed because the repo has no CI status checks; without it Renovate waits forever for checks that never come.

Dashboard issue

With dependencyDashboard: true Renovate keeps one issue in the repo ("Renovate: container update dashboard") listing open PRs, updates waiting for approval (tick the box to get the PR), rate-limited updates and lookup errors. It's the first place to look when "Renovate isn't doing anything".


Why there's no minimumReleaseAge

A common recommendation is to wait a few days after a release before automerging (minimumReleaseAge: "3 days"), so the bad ones get pulled upstream first. That doesn't work for container images from ghcr.io, lscr.io or Docker Hub: those registries don't give Renovate a release timestamp. With the default minimumReleaseAgeBehaviour (timestamp-required), an update with no timestamp is held forever, so nothing ever merges. The daytime-only automerge window is the safety net instead.

Rate limits

  • config:recommended limits Renovate to 2 new PRs per hour (and a cap on concurrent PRs). After a big pinning change you get a backlog that trickles in over a day; the dashboard lists the rest as rate-limited. That's fine.
  • Registries rate-limit too. LinuxServer's registry sometimes returns no tag list for a few images in one run; the next hourly run usually gets them. Log in to Docker Hub with RENOVATE_HOST_RULES so lookups don't count against the anonymous limit.
  • Without a github.com token, Renovate can't fetch release notes for most projects and the dashboard warns about it. A GitHub personal access token with no scopes, passed as GITHUB_COM_TOKEN (or a github.com host rule), fixes that.

Sources & further reading