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.
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
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.
{
"$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:recommendedlimits 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_RULESso 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 agithub.comhost rule), fixes that.
Sources & further reading¶
- Renovate: Forgejo platform
- Renovate: Self-hosted configuration (
gitUrl,endpoint,onboarding,persistRepoData) - Renovate: Configuration options
- Renovate: Automerge
- Renovate: Dependency Dashboard
- Renovate: Docker