Dependency updates are one of those chores that quietly decides how secure and how maintainable a codebase is. Ignore them and you accumulate a slow-motion liability: unpatched CVEs, abandoned libraries, base images that drift years out of date. Automate them carelessly and you have just built a machine that merges other people’s code into production while you sleep.

I run a little over fifty repositories under one GitLab group: container images, a handful of Python services, Ansible, Docker Swarm stacks, even this blog. Keeping their dependencies current by hand is not realistic, so Renovate does it. I run it myself, on my own CI, under one configuration shared by every repository. Two ideas shape that configuration: trust is a spectrum, and the update pipeline itself is something an attacker would love to own.

The whole thing lives in one public repository, egos-tech/renovate-config. Everything below links straight to the file it describes, so you can lift any piece of it.

Self-host the bot, do not hand over the keys

The hosted Renovate app is a fine choice for plenty of teams. But installing it means granting a third-party application write access to every repository in your group. For a one-person platform where the whole point is to minimise who can touch production, that trade did not sit right. Self-hosting means the bot runs on my own CI runners, with a token I issue and can revoke, on a schedule I control.

The runner is a scheduled GitLab CI job. The important detail is in the .gitlab-ci.yml: the Renovate image is pinned to a digest.

.renovate:
  image: ghcr.io/renovatebot/renovate:43.243.2@sha256:9e7084cd44ac8be22639b08c15d81617c2f2c9b9188f062d522a2a696984ef26

This is the first instance of a habit that runs through the entire setup: if something can be pinned to a digest, it is. A tag like 43.243.2 can be repointed at a different image after the fact; a sha256: digest cannot. The bot that rewrites everyone else’s dependencies is exactly the component you least want silently swapped underneath you. Renovate bumps its own pin like any other dependency, so it stays current on its own.

Credentials never live in the config. They are injected from CI variables in config.js, and each hostRules entry is scoped as narrowly as it can be:

module.exports = {
    hostRules: [
        {
            matchHost: 'https://gitlab.com/api/v4/groups/118000822/-/packages/pypi/',
            hostType: 'pypi',
            username: 'egos-tech-automation',
            password: process.env.EGOS_BOT_RENOVATE,
        },
        {
            matchHost: 'ghcr.io',
            hostType: 'docker',
            username: process.env.GITHUB_USERNAME,
            password: process.env.RENOVATE_GITHUB_COM_TOKEN,
        },
        // ...
    ],
};

Note the PyPI rule: it is scoped to the group package registry path alone. Private package names resolve to the GitLab registry; public ones fall through to PyPI. The bot’s credentials are only ever offered to the host they belong to.

One preset, fifty repositories

Every repository carries the same tiny renovate.json:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": [
    "local>egos-tech/renovate-config"
  ]
}

That is the entire per-repository footprint. All behaviour lives in the shared default.json, so a policy change is one merge in one place that every repository picks up on its next run. New repositories inherit it automatically through the onboardingConfig, which extends the same preset, so onboarding a repo is nothing more than letting the bot discover it.

Discovery is where “scale” stops being a word and starts being a CI design problem. A single autodiscover run across fifty-odd repositories is slow and gives you one giant log to debug. So the pipeline shards by namespace, with each shard a separate job:

services:
  extends:
    - .stage:deploy
  variables:
    RENOVATE_AUTODISCOVER_NAMESPACES: '["acme/services"]'
    RENOVATE_AUTODISCOVER_FILTER: '["!/acme\/services\/legacy/"]'
    RENOVATE_EXTRA_FLAGS: "--autodiscover"

The jobs partition the group cleanly: the top-level repositories get one job, and each major subgroup gets its own, with filters carving out the handful of repos that belong to a different shard. The filters use negative-lookahead regex so a repo is owned by exactly one job and never updated twice in a run. There are also manual escape hatches for operating on a single repository on demand, which helps when you are debugging one repo’s config and do not want to wait for the full sweep.

Automerge as graded trust

This is the core of the setup. “Should this update merge without a human looking at it?” depends entirely on how much you trust the change. The default.json packageRules encode that trust as a gradient.

Patch, minor, digest, pin and lockfile updates automerge:

{
    "automerge": true,
    "matchUpdateTypes": [
        "minor", "patch", "pin", "pinDigest", "digest", "lockFileMaintenance"
    ]
}

Major updates never do. They get a label, a warning note on the merge request, and they wait for me:

{
    "automerge": false,
    "labels": ["renovate", "renovate::major"],
    "matchUpdateTypes": ["major"]
}

The reasoning is the obvious one: a major bump is, by the maintainer’s own declaration, allowed to break things. That is precisely the change a human should read release notes for. Everything below it is the kind of churn that is far more dangerous to leave undone than to apply.

But automerge on its own would happily merge a version that was published ninety seconds ago, which is exactly the window in which a compromised release does its damage before anyone notices and yanks it. So there is a cooldown, set by minimumReleaseAge:

"minimumReleaseAge": "2 days",
"minimumReleaseAgeBehaviour": "timestamp-optional",

A release has to have existed for two days before the bot will touch it. If a package version is pulled because it was malicious or simply broken, that almost always happens within the first day or two, and the cooldown means I never auto-adopted it. It is a blunt supply-chain defence, and it works. Combined with internalChecksFilter set to strict, the bot also refuses to merge anything whose own internal checks have not cleared.

The interesting exception is trust in your own code. Packages I publish myself, under my own registry, skip the cooldown entirely:

{
    "matchPackageNames": [
        "/registry\\.gitlab\\.com\\/egos\\-tech\\/.*/",
        "/ix(_|-)notifiers/",
        "/egos(-|_)helpers/"
    ],
    "minimumReleaseAge": null
}

There is no point making my own freshly-cut release sit in a cooldown designed to protect me from strangers. This is the graded-trust idea taken to its logical end: the cooldown length is a dial, and how far you turn it depends on who published the thing.

Surfacing where the risk is

A dependency bot that only raises the version number is doing half the job. The other half is telling you where the risk is. The shared config turns on the vulnerability and end-of-life signals Renovate can produce, starting with osvVulnerabilityAlerts:

"osvVulnerabilityAlerts": true,
"dependencyDashboardOSVVulnerabilitySummary": "all",
"dependencyDashboardReportAbandonment": true,
"extends": [
    "abandonments:recommended",
    "...": "..."
]

Every repository gets a dependency dashboard issue that summarises known OSV vulnerabilities across its dependencies, and flags packages that look abandoned. That last signal is underrated. An unmaintained dependency is a quiet liability today and the seed of a loud one later. Seeing it on the dashboard months early gives you time to plan a calm migration, well ahead of the scramble that follows a CVE landing with no patch coming.

The update pipeline is attack surface

Here is the part people skip. Renovate can run shell commands after an upgrade, via postUpgradeTasks. That feature is useful, and it is also a remote-code-execution surface with your bot’s credentials attached. If any command could run, then any repository the bot touches could, in principle, make it run something.

So commands are allowlisted via allowedCommands. The bot will only execute scripts named explicitly in config.js:

allowedCommands: [
    'sync-alpine-packages.sh',
    'vendor-refresh.sh',
],

and shell execution for post-upgrade commands is enabled deliberately and narrowly through allowShellExecutorForPostUpgradeCommands, in alpine.json, scoped to the one place that needs it. The rule of thumb: every capability you grant the bot is a capability an attacker inherits if they get a foothold in any repo it manages. Allowlisting turns “run anything” into “run these two known scripts,” which is a far smaller surface.

The next two sections cover what those two allowed commands actually do.

A custom datasource for Alpine packages

If you pin Alpine packages in a Dockerfile for reproducibility, like this:

# renovate: datasource=repology depName=alpine_3_21/curl
ARG CURL_VERSION="8.12.1-r0"
RUN apk add --no-cache "curl=${CURL_VERSION}"

you run into a wall. Renovate’s usual route for this is Repology, and it is unreliable for Alpine: the data lags, and a pin like 8.12.1-r0 is tied to a specific Alpine release series. When the base image moves from 3.21 to 3.22, the package version that actually exists changes too, and Repology will not track that cleanly.

So the repo builds its own datasource. A hook, serve-alpine-apk-datasource.sh, downloads the real APKINDEX for each Alpine series straight from the Alpine mirror and serves the current version of every package over a tiny local HTTP endpoint. Renovate is pointed at it as a customDatasources entry in alpine.json:

"customDatasources": {
    "alpine-apk": {
        "defaultRegistryUrlTemplate": "http://127.0.0.1:17324/{{packageName}}.version",
        "format": "plain"
    }
}

The datasource binds to 127.0.0.1 only, so it is reachable by the bot in the same job and nothing else. The CI script starts it just before running Renovate.

That solves reading the right version. The second half is writing the pins back, and it has a subtlety: an Alpine package pin is only valid for the base image it was resolved against. Bumping curl without bumping the FROM alpine:3.21 line, or vice versa, gives you a pin that does not exist. So the two are grouped to move together:

{
    "description": "Update Alpine base image versions and APK pins together because packages can depend on matching builds",
    "groupName": "Alpine base image and APK package pins",
    "matchDepTypes": ["alpine", "alpine-package"]
}

and when the base image bumps, the sync-alpine-packages.sh post-upgrade hook rewrites the package pins in the Dockerfile to the versions that exist in the new series, all in the same merge request. The base image moves, the package pins follow, and the result is always internally consistent. This is the kind of glue that turns “pin everything for reproducibility” from an aspiration into something you can actually live with.

Keeping vendored assets current without a CDN

This blog, like a few of my sites, vendors its third-party front-end assets: fonts, FontAwesome, a lightbox library. They are committed into the repo and served from my own origin, with no third-party CDN touched at page load. That is a deliberate hardening choice. A CDN dependency is a third party you trust on every page load, and it is the single biggest obstacle to a strict Content-Security-Policy. Vendoring removes both problems.

The cost of vendoring is that the assets go stale, because no package manager is tracking them. So the repo generalises the pattern. Each project keeps a scripts/vendor-*.sh that pins its asset version with a Renovate annotation and re-fetches the file. A customManagers entry in vendor.json reads those pins, and a single post-upgrade task re-runs the fetch when a pin bumps:

{
    "matchDepTypes": ["vendored-asset"],
    "postUpgradeTasks": {
        "commands": ["vendor-refresh.sh"],
        "executionMode": "branch",
        "fileFilters": ["**/vendor/**"]
    }
}

vendor-refresh.sh is deliberately generic: it runs every scripts/vendor-*.sh the repository happens to ship, re-downloads the asset at its new pinned version, and commits the result under vendor/. A repository with no vendor scripts gets a clean no-op, so the preset is safe to extend everywhere. The assets stay off the CDN, stay friendly to a strict CSP, and still keep themselves current, with a human reviewing the diff.

Get the commit metadata right

A quieter lesson, learned the hard way. The config uses semantic commits, and it is tempting to take a shortcut and label every dependency update as something coarse. I once had a rule that effectively tagged dependency bumps in a way that downstream release automation read as breaking. The result was a stream of commits that lied to the changelog and tooling about the nature of the change.

The fix was to map commit types honestly: a dependency bump registers as a fix-class change for release purposes, and test-only or dev-only dependencies get their own scope so they never inflate a release. meta.json carries that:

{
    "additionalBranchPrefix": "dev-deps/",
    "automerge": true,
    "matchDepTypes": ["dev", "devDependencies"],
    "semanticCommitScope": "dev-deps"
}

If you automate commits, those commits are an input to other automation. Garbage in, garbage out applies to commit metadata as much as to anything else.

It is tested

A configuration repository that everything else depends on deserves the same rigour as code, so the pipeline gates itself. Every merge request runs renovate-config-validator over the JSON presets, so a broken preset is caught before it reaches a single managed repository. It then runs a dry run in its own mode, distinct from the scheduled jobs: it autodiscovers the top-level repositories with the private subgroups filtered out, runs at debug log level, and previews every change Renovate would make without opening a merge request. The shell hooks have their own test harnesses in tests/. And every run, scheduled or dry, writes a debug-level renovate-log.json that the pipeline keeps as an artifact for a day; that log is the first thing I reach for when a run does something surprising. When the thing that updates fifty repositories is itself wrong, the blast radius is fifty repositories, and these guards are what let me change it without flinching.

Takeaways

A few principles carry the whole setup. Automerge works as a gradient of trust, with the release-age cooldown dialled by who published the code: patch and minor through, majors held for a human, and the cooldown set to zero for code I publish myself. The bot is privileged infrastructure, so it earns the same caution as production: credentials I own and can revoke, a digest-pinned image, host rules scoped to single hosts, and an allowlist for any command it may run, because every capability I grant it an attacker would inherit too. And the policy lives in one shared preset while discovery is sharded by namespace, which is what keeps the whole thing workable past a couple of dozen repositories.

The whole configuration is public at egos-tech/renovate-config. Clone the parts that fit.

This blog entry has been written by me and reviewed with the help of AI. The illustration is AI-generated.