Contents

Renovate for Homelab Compose Files

Turning image-tag drift into pull requests you can actually review

Contents

I counted the compose files in my homelab repo last month: nineteen stacks, sixty-three image references, and a git log that showed several of them had not been touched in fourteen months. I had forgotten they existed. The media stack gets updated because I notice when it breaks. The little utility that converts webhooks into push notifications gets updated when I remember it exists, which is roughly never.

That is the actual problem Renovate solves. It is a bot that reads your repository, works out what versions your dependencies are pinned to, checks what versions exist upstream, and opens a pull request when there is a newer one. Dependabot does the same job for GitHub-hosted repos, and does it well, but Renovate understands Docker Compose files properly, runs anywhere, and lets you configure it in ways that make the noise bearable. For a homelab repo full of compose files, that combination matters more than the polish.

Why the drift is worse than it looks

Advertisement

The obvious argument for keeping images current is security. That argument is real but overstated for a homelab — most of the containers behind my reverse proxy are not reachable from the internet, and the CVE flood is mostly noise once you filter for “actually exploitable in my configuration”. I have written about which CVEs actually matter for a homelab and the honest answer is: fewer than the scanners suggest.

The better argument is upgrade debt. If you are on version 1.4 of an application and current is 3.2, you are no longer one upgrade away from current — you are one upgrade away from a wall of breaking changes, a database migration that only runs from 2.x, and a maintainer telling you to restore from backup and start again. I have done exactly that with a self-hosted wiki, and the migration path involved standing up an intermediate version purely to run its schema migration, then upgrading again. Two evenings, entirely self-inflicted.

Small, frequent, reviewable upgrades avoid that. Renovate makes small and frequent the default, and the review step is what separates it from Watchtower-style auto-pulling, where the first you hear about a breaking change is when the service stops answering.

What Renovate does to a compose file

Renovate has a “docker-compose” manager that parses image: lines. Give it this:

1
2
3
4
5
6
7
8
9
services:
  paperless:
    image: ghcr.io/paperless-ngx/paperless-ngx:2.14.7
    restart: unless-stopped
    depends_on:
      - broker
  broker:
    image: docker.io/library/redis:7.4.2-alpine
    restart: unless-stopped

and it will notice that 2.14.7 is a semver-shaped tag on a registry it can query, look up the tag list, decide 2.15.0 is a minor bump, and open a PR that changes exactly that one line. The PR body includes the release notes it scraped from the upstream changelog, which is the single most useful thing about the tool. I have rejected updates purely on the strength of a release note that said “the config file format has changed”.

What it cannot do is guess. If your image is image: someapp:latest, there is no version to compare, and Renovate will silently skip it. Same for image: someapp with no tag at all. Pinning to a real tag is the price of entry, and it is a price worth paying regardless.

Running it on your own hardware

Advertisement

The hosted GitHub App is fine if your compose repo lives on GitHub. Mine lives on a self-hosted Gitea instance on the LAN, so Renovate runs as a scheduled container against it. The whole thing is three files.

First, the compose stack:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
services:
  renovate:
    image: ghcr.io/renovatebot/renovate:39.264.0
    environment:
      RENOVATE_PLATFORM: gitea
      RENOVATE_ENDPOINT: https://git.mylab.local/api/v1
      RENOVATE_AUTODISCOVER: "true"
      RENOVATE_AUTODISCOVER_FILTER: "smarc/homelab-*"
      RENOVATE_GIT_AUTHOR: "Renovate Bot <[email protected]>"
      RENOVATE_TOKEN: ${RENOVATE_TOKEN}
      GITHUB_COM_TOKEN: ${GITHUB_COM_TOKEN}
      LOG_LEVEL: info
    volumes:
      - ./config.js:/usr/src/app/config.js:ro
      - renovate-cache:/tmp/renovate

volumes:
  renovate-cache:

Two tokens, and the second one confuses everybody. RENOVATE_TOKEN is the Gitea token the bot uses to clone and open PRs — give it a dedicated bot account with write access to the repos you care about, not your own account, because you will want to filter the notifications later. GITHUB_COM_TOKEN is a read-only GitHub token used purely for fetching release notes and changelogs from projects hosted on github.com. Without it you hit anonymous rate limits within about ninety seconds and your PR bodies arrive empty. It needs no scopes at all — a bare classic token with nothing ticked works fine.

Then config.js, which is global bot configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
module.exports = {
  onboarding: true,
  onboardingConfig: {
    extends: ['config:recommended'],
  },
  repositories: [],
  dryRun: null,
  hostRules: [
    {
      matchHost: 'registry.mylab.local',
      username: 'renovate',
      password: process.env.LOCAL_REGISTRY_TOKEN,
    },
  ],
};

The hostRules block is how you teach it about a private registry. If you run your own registry with anything other than anonymous pull, Renovate needs credentials or it will report every one of those images as “no versions found” and you will spend an hour reading logs.

Finally, a systemd timer to run it. Renovate is a batch job — it wakes up, scans, opens PRs, exits.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# /etc/systemd/system/renovate.timer
[Unit]
Description=Run Renovate against the homelab repos

[Timer]
OnCalendar=Mon,Thu 04:00
RandomizedDelaySec=1800
Persistent=true

[Install]
WantedBy=timers.target

Twice a week, at four in the morning, with a random delay so I am not hammering Docker Hub on the same second as everyone else who copied the same tutorial. Daily is too often for a homelab; you end up with a PR queue you resent.

The per-repo config that makes it usable

The default configuration is tuned for a software team that wants every update immediately. A homelab wants something calmer. This is the renovate.json that lives in the compose repo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended", ":dependencyDashboard"],
  "timezone": "Europe/Copenhagen",
  "schedule": ["before 6am on monday"],
  "prConcurrentLimit": 3,
  "prHourlyLimit": 0,
  "packageRules": [
    {
      "matchDatasources": ["docker"],
      "matchUpdateTypes": ["minor", "patch"],
      "groupName": "container patches",
      "automerge": false
    },
    {
      "matchDatasources": ["docker"],
      "matchUpdateTypes": ["major"],
      "labels": ["breaking"],
      "dependencyDashboardApproval": true
    },
    {
      "matchPackageNames": ["docker.io/library/postgres"],
      "matchUpdateTypes": ["major"],
      "enabled": false
    }
  ]
}

Four decisions worth explaining, because each one came from getting it wrong first.

:dependencyDashboard creates a single issue in the repo listing every pending update, including the ones being held back. It is the only view I actually read. Without it, suppressed updates are invisible and you slowly lose track of what the bot is deciding on your behalf.

Grouping minor and patch updates into one PR per run turns nineteen pull requests into one. I merge it after a glance at the release notes. When something breaks, git revert takes the whole batch back, which is exactly the granularity I want at 11pm.

Major updates get dependencyDashboardApproval: true, which means Renovate will not open the PR until I tick a checkbox on the dashboard issue. Major bumps need a maintenance window and a backup check, and having them sit as a nagging open PR trains you to ignore open PRs.

Postgres major upgrades are disabled outright. A Postgres major version bump inside a container is a data migration wearing a version number’s clothing — the container starts, finds a data directory initialised by the previous major, and refuses to run. Renovate opening that PR helpfully is worse than useless, because merging it looks harmless. Turn it off and handle those by hand with a pg_dump in front of them, as covered in database backups done right.

Digest pinning: worth it, mostly

Renovate can rewrite image: caddy:2.8.4 into image: caddy:2.8.4@sha256:e1a2b3... and then keep the digest current when upstream rebuilds the tag. This is genuinely valuable, because a mutable tag is a promise nobody enforces. 2.8.4 today and 2.8.4 in six months can be different images — rebuilt with a patched base layer if you are lucky, or replaced entirely if a maintainer’s account is compromised. Digest pinning is one of the cheapest defences against the kind of registry tampering described in supply chain attacks.

Turn it on with "extends": ["docker:pinDigests"]. The cost is aesthetic and real: your compose files become unreadable walls of hex, and every base-image rebuild upstream generates a PR that changes nothing you can see. I pin digests on the handful of stacks that face the internet and leave the internal ones on plain tags. That is a compromise rather than a principle, and I am at peace with it.

Troubleshooting

Advertisement

Everything reports “no new version found”. Nine times out of ten the tag is not semver-shaped. Images tagged 2024-11-3 or v3.1-ubuntu need an explicit versioning hint in a package rule — "versioning": "regex:^v?(?<major>\\d+)\\.(?<minor>\\d+)" or one of the built-in schemes like loose. Run with LOG_LEVEL=debug and grep for the package name; Renovate logs its reasoning in full and the reason is always in there.

Rate limited by Docker Hub. Anonymous pulls from Docker Hub are throttled per IP, and Renovate’s tag lookups count. Add a hostRules entry with a free Docker Hub account’s credentials, which raises the limit enough that a twice-weekly run never notices.

The bot opens PRs against a branch you deleted. Renovate caches branch state in /tmp/renovate. If you rewrite history or rename the default branch, wipe the cache volume. It is a cache; nothing of value is lost.

PRs have empty bodies and no release notes. Missing or expired GITHUB_COM_TOKEN. The bot does not fail loudly for this — it just quietly gives you nothing to review, which defeats the point of the whole exercise.

Renovate reformats your compose file. It should only touch the lines it changes, but if your YAML has unusual indentation or inline flow mappings, the round-trip can shift things. Keep compose files boringly formatted and this never comes up. Consistent formatting also makes the diffs reviewable, which is the entire product here.

Beyond compose files

The repo has more in it than compose files, and Renovate reads most of it without being asked. That was an unexpected bonus rather than a design goal on my part.

It picks up FROM lines in Dockerfiles, image references inside Kubernetes manifests and Helm values, GitHub Actions versions in workflow files, and the pinned versions in a .pre-commit-config.yaml. My homelab repo has all of those, and turning Renovate on against it surfaced a CI action pinned to a major version that had been deprecated for eighteen months.

The interesting extension is custom managers, for versions that live somewhere Renovate has no manager for. A shell script that downloads a specific release, for instance:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "customManagers": [
    {
      "customType": "regex",
      "managerFilePatterns": ["/^scripts/bootstrap\.sh$/"],
      "matchStrings": [
        "# renovate: datasource=(?<datasource>.*?) depName=(?<depName>.*?)\s+VERSION="(?<currentValue>.*)""
      ]
    }
  ]
}

…paired with a comment in the script itself:

1
2
3
# renovate: datasource=github-releases depName=restic/restic
VERSION="0.17.3"
curl -fsSLO "https://github.com/restic/restic/releases/download/v${VERSION}/restic_${VERSION}_linux_amd64.bz2"

Renovate now tracks a version number in a bash script. This is the feature that turns it from “a tool for compose files” into “a tool for anything with a version in it”, and it is worth an hour of your time if you have scripts like that. I have four.

What Renovate cannot see

Worth being honest about the blind spots, because I assumed for months that a green dependency dashboard meant my stacks were current.

Renovate updates references, and a compose file is mostly references. It has no opinion about the bind-mounted config file next to it. If an upstream release changes a configuration key, Renovate will happily bump the tag, the container will start, log a parse error, and exit — and the PR that caused it will show a one-character diff. This is why the release notes in the PR body are the product and the version number is just the trigger.

It also cannot see anything you install outside the image. The reverse proxy image gets bumped; the plugin you baked into a volume by hand does not. Anything installed with curl | bash on the host is invisible to it, and always will be.

And it has nothing to say about whether an upgrade is a good idea. It reads registries and has no judgement to offer. A project that has been quietly abandoned still cuts releases — sometimes more of them, as a single remaining maintainer churns through dependency bumps of their own. A steady stream of green PRs can look like health while the project drifts toward unmaintained. Reading the release notes protects you from this; merging on autopilot does not.

There is a version of this tool that does the merging for you. automerge: true on patch updates is tempting, and for a team with a test suite it is the correct answer. A homelab has no test suite. The closest I have is “does the dashboard still load”, and I am the one who checks. So I leave automerge off everywhere and accept the five minutes a week. If you have real health checks wired into a monitoring stack and the discipline to alert on them, automerging patch bumps on non-critical stacks is defensible. I would still leave the database out of it.

Is it worth it?

For a repo with three compose files, no. Run docker compose pull when you think of it and get on with your life. The setup cost is an evening and the ongoing cost is a PR queue you have to actually read.

For a repo with fifteen or more stacks, especially if some of them are things you set up once and forgot, it changes the failure mode entirely. Instead of discovering in 2027 that your document scanner is four majors behind and no longer has a migration path, you get a small, reviewable diff most weeks. The dependency dashboard doubles as an inventory: it is the only place in my setup that lists every image I run alongside how far behind it is.

The thing that surprised me is that Renovate is most useful for the services I care about least. The important ones I would have updated anyway. The forgotten ones — the webhook shim, the DNS logger, the little status page — are the ones that rot, and those are exactly the ones a bot is good at nagging about. Set the schedule to weekly, group the patches, put major bumps behind a checkbox, and it becomes a five-minute Monday habit rather than a project.

Set it up on the repo you have been avoiding. That is where the payoff is.

Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.