Skip to content
Fran Gonzalez
← Back to blog
·Clanker·15 min read

Self-hosting Renovate on GitLab CI

Running Renovate as a scheduled GitLab CI job instead of the hosted GitHub App: the execution model, the token strategy, config validation in CI, and the tradeoffs accepted at each step.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

Previous posts in this series covered Renovate config hardening and closing the transitive-CVE blind spot. Both assumed the hosted GitHub App. The repo these patterns landed in is on GitLab. The App cannot reach it. This post covers self-hosting Renovate as a scheduled GitLab CI job: the execution model, the token, config validation, and the tradeoffs each piece introduces.

The Problem

The hosted Renovate GitHub App works for GitHub repositories. GitLab has no equivalent hosted integration, citing CI security concerns1. Dependabot is GitHub-only. The options for a GitLab repository are: pay for Mend Renovate Enterprise, self-host the official renovate/renovate Docker image, or abandon automated dependency updates.

I chose self-hosting. The repo already runs on a self-hosted GitLab runner, and Renovate’s image is a container that runs on a schedule. The alternative, a separate dedicated Renovate service project with cross-project discovery, is the pattern the official renovate-runner template and most existing guides recommend2. I chose an in-repo per-project setup instead. The tradeoffs of that choice run through the rest of this post.

This setup runs on GitLab Free (SaaS). Every pattern described here, including the improvements in the final section, works on the Free tier. Where a feature has tier restrictions (specifically Project Access Tokens on SaaS Free), I call it out.

The Execution Model

The scheduled CI job

Renovate runs as a single GitLab CI job, renovate:run, defined in the same pipeline include graph as the rest of the monorepo CI:

# ci/projects/supply-chain.yml
renovate:run:
  stage: admin
  needs: []
  # In the real file this is a shared .renovate_image anchor (single source
  # of truth for both jobs); the literal is expanded here for readability.
  image:
    name: renovate/renovate:43.150.0@sha256:f2d4c467a8eb4b885630a8ca7d068173db69a5a1156ba41480c0a3a2e011d759
    entrypoint: [""]
  environment:
    name: renovate
    action: access
  resource_group: renovate
  variables:
    GIT_STRATEGY: none
    LOG_LEVEL: info
    RENOVATE_AUTODISCOVER: "false"
    RENOVATE_ENDPOINT: "$CI_API_V4_URL"
    RENOVATE_ONBOARDING: "false"
    RENOVATE_PLATFORM: "gitlab"
    RENOVATE_REQUIRE_CONFIG: "required"
    RENOVATE_BASE_DIR: "$CI_PROJECT_DIR/.renovate"
  cache:
    key: "renovate-cache-v1"
    paths:
      - .renovate/cache/
    policy: pull-push
  before_script:
    - renovate --version
  script:
    - renovate "$CI_PROJECT_PATH"
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'
      when: always
    - when: manual
      allow_failure: true
  timeout: 30m
  interruptible: true

Several decisions in this job definition are worth explaining.

GIT_STRATEGY: none tells the runner to skip the checkout. Renovate clones the repository itself via the API token, so a runner-side checkout is wasted work. This is the standard pattern in the Renovate GitLab self-hosting docs2.

RENOVATE_AUTODISCOVER: "false" combined with renovate "$CI_PROJECT_PATH" scopes Renovate to this repository only. Autodiscover would scan every project the token can see, which is the multi-project pattern. A per-project setup avoids that blast radius.

RENOVATE_ONBOARDING: "false" and RENOVATE_REQUIRE_CONFIG: "required" mean Renovate will never create an onboarding MR and will hard-fail if renovate.json5 is missing. The config is committed before the first run, so onboarding is unnecessary.

stage: admin and needs: [] detach the job from the build and test DAG. Renovate runs after deploys finish, but it does not wait for them. The job runs on schedule or manually, with a 30-minute timeout.

A manual trigger while a scheduled run is active would race two Renovate processes on the same repository. resource_group: renovate serializes them: the second trigger queues behind the first. Available on Free tier, and shown in the job definition above.

Tradeoff accepted: running in-repo means the Renovate job definition lives in the same CI config it maintains. A broken include:local path in the monorepo CI graph takes down both the normal pipeline and Renovate. The Siemens pattern uses a dedicated orphan branch (renovate-bot) to isolate the two3. That adds operational overhead: a second branch to maintain, a separate pipeline schedule targeting that branch, and a cross-branch include reference. I accepted the coupling for simplicity in a single-team repository.

Pipeline schedules

GitLab CI has no cron: key in the YAML. Schedules are configured in the project UI at CI/CD > Schedules, and the YAML gates on the pipeline source:

# .gitlab-ci.yml
workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
    # ...

Two schedules trigger renovate:run:

ScheduleCronPurpose
Renovate weekly0 2 * * 1 (Mon 02:00 UTC)Dependency update MRs
Renovate lockfile0 3 * * 0 (Sun 03:00 UTC)Lockfile maintenance

The workflow:rules block must include $CI_PIPELINE_SOURCE == "schedule" or scheduled pipelines never create a graph. This is easy to miss when the rules list is initially written for MR and push events only.

Tradeoff accepted: the schedules are UI configuration, not version-controlled YAML. A deleted schedule silently disables all dependency updates. There is no audit trail in the repository for schedule changes. The GitLab API can create and manage schedules programmatically, but I have not scripted that yet.

Security vulnerability PRs bypass the weekly schedule. The vulnerabilityAlerts.schedule: null setting in renovate.json5 lets OSV-driven alerts open at any time, subject to the 1-day minimumReleaseAge cooldown documented in the config hardening post.

The Token

The token question has a clean answer and a messy reality.

The clean answer: Renovate should authenticate with a Project Access Token (PrAT). A PrAT is scoped to this repository, revocable without touching any maintainer’s account, and survives the person who created it. It is the right shape for a bot.

The messy reality: this repository lives under a group namespace on GitLab.com SaaS Free, and on SaaS Free, PrATs require Premium or Ultimate for group-namespace projects4. So the setup uses a user-scoped Personal Access Token instead: a dedicated maintainer account’s PAT with api and write_repository scopes5, stored as a masked, protected CI/CD variable (RENOVATE_TOKEN). On self-managed GitLab, or on any paid SaaS tier, swap in a PrAT and nothing else in the CI changes; the config is token-source-agnostic.

The tradeoff is real and worth naming. A PAT is user-scoped rather than project-scoped, so it carries that user’s permissions across every project they can access, and it ties Renovate’s identity to a person who will eventually leave the team. The mitigation is operational, not technical: a dedicated bot-style maintainer account owns the PAT (not a day-to-day human account), and the offboarding steps live in the runbook. If the repo ever moves to a tier where PrATs are available, the PAT is the first thing to retire.

To keep Renovate’s commits attributed to a bot rather than the PAT-owning account, renovate.json5 sets a gitAuthor:

// renovate.json5
gitAuthor: "renovate-bot <renovate-bot@local>",

A second variable, RENOVATE_GITHUB_COM_TOKEN, holds a GitHub PAT (public_repo scope) for changelog lookups on GitHub-hosted dependencies. Unlike RENOVATE_TOKEN, it is deliberately left at the default * (all) environment scope rather than narrowed to renovate. Its only scope is public_repo, and every repository in the group is private, so the token cannot read, write, or list any company code. The worst case is exposure to the one non-sensitive thing it can do, public changelog lookups. Narrowing the scope adds UI ceremony without reducing a real attack surface. (Nuance: a classic GitHub PAT with public_repo is technically read+write to public repos the owner belongs to, not read-only despite the name. A fine-grained read-only PAT makes the “no real permissions” claim literally true.)

Environment-scoped variables

By default, every CI/CD variable in GitLab has an environment scope of * (wildcard), meaning every job in every pipeline receives every variable. In a monorepo with 30+ jobs (build, test, deploy across Java, Nuxt, Python, Terraform), that means every job can read RENOVATE_TOKEN.

GitLab’s environment keyword narrows this. When a variable is scoped to a named environment like renovate, only jobs that declare environment: { name: renovate } in their YAML receive it. The action: access value tells GitLab the job reads an existing environment without creating a deployment record in the Environments dashboard. This is a Free-tier feature.

Both Renovate jobs declare the environment, so the renovate environment is exactly “the two Renovate jobs”:

# ci/projects/supply-chain.yml
renovate:validate:
  environment:
    name: renovate
    action: access

renovate:run:
  environment:
    name: renovate
    action: access

The validate job does not call the GitLab API and never consumes the token, but declaring the environment there too keeps the two jobs uniform and means a variable scoped to renovate can only ever reach a Renovate job. RENOVATE_TOKEN is scoped to renovate in the GitLab UI (Settings > CI/CD > Variables > edit > set Environments from * to renovate). After this, a backend test job, a frontend build job, or a deploy job cannot read it. The blast radius of a compromised CI job is bounded to the run job alone. (RENOVATE_GITHUB_COM_TOKEN stays at * for the reasons in the previous section.)

The Siemens analysis of GitLab’s CI job token privilege escalation documents a concrete attack where a malicious project in the CI job token allowlist can execute actions with Renovate’s elevated permissions when Renovate triggers a pipeline on a target project3. The Siemens solution goes further: an approval gate that triggers a child pipeline only after a human approves the Renovate MR. My setup does not implement this gate. The exposure applies to repositories with private inner-source dependencies where the CI job token crosses project boundaries. This monorepo has no such cross-project CI job token allowlists, so the attack surface is smaller. The tradeoff is documented, not ignored.

The Config

The full renovate.json5 walkthrough is in the config hardening post. This section covers only the GitLab-specific parts.

registryAliases

Renovate’s GitLab CI manager extracts Docker image references from .gitlab-ci.yml and included files. When images use GitLab’s predefined CI variables as prefixes, Renovate cannot resolve them without a mapping:

// renovate.json5
registryAliases: {
  $CI_REGISTRY: "registry.gitlab.com",
  $CI_SERVER_FQDN: "gitlab.com",
  $CI_SERVER_HOST: "gitlab.com",
},

The monorepo CI uses ${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/node:26.1.0-alpine3.23@sha256:... for Docker Hub images pulled through the GitLab Dependency Proxy. Without registryAliases, Renovate would skip these references and never propose digest bumps.

The Siemens post passes the same mapping via the RENOVATE_REGISTRY_ALIASES environment variable3. Putting it in renovate.json5 keeps all Renovate config in one file at the cost of hardcoding the concrete host (registry.gitlab.com) rather than letting it resolve dynamically from the CI environment. For a single GitLab instance, the hardcoded value is stable. For a setup that mirrors across multiple GitLab instances, the environment variable form is more portable.

Self-bumping and version management

Renovate opens an MR to bump the renovate/renovate:43.150.0@sha256:... digest in ci/projects/supply-chain.yml, the file that defines the job that runs Renovate. The ci-images package rule groups this with the Trivy image so both land in one MR:

// renovate.json5
{
  description: "Group CI images (trivy, renovate) for dedicated review",
  matchPackageNames: ["aquasec/trivy", "renovate/renovate"],
  groupName: "ci-images",
  addLabels: ["ci"],
},

The version 43.150.0 lives in two artifacts, managed by two Renovate managers:

LocationArtifactRenovate manager
ci/projects/supply-chain.yml (YAML anchor)Docker image (CI jobs)gitlabci
mise.toml "npm:renovate" = "43.150.0"npm package (local validation)mise

A second package rule groups the npm renovate package into the same ci-images group, so both bump in one MR:

// renovate.json5
{
  description: "Group the npm renovate package with its Docker image so both bump in one MR",
  matchDatasources: ["npm"],
  matchPackageNames: ["renovate"],
  groupName: "ci-images",
  addLabels: ["ci"],
},

The local validate task uses mise x renovate -- renovate-config-validator, which resolves the npm package from mise.toml. No version is hardcoded in the task. mise.lock pins the checksum. This follows the same single-source-of-truth pattern as every other tool in the repo (node, pnpm, uv): mise.toml declares the version, mise.lock pins it, and Renovate’s mise manager bumps it.

I arrived at this after first hardcoding the version in the mise task (npx --package renovate@43.150.0), which drifted the first time Renovate bumped the CI image without me remembering to update the task. Adding npm:renovate to mise.toml [tools] eliminates the drift class rather than patching it, the same structural fix documented in the Corepack drift TIL.

Tradeoff accepted: the npm package and Docker image are technically separate artifacts from different datasources. If the npm publish and Docker publish ever fall out of sync, the versions could diverge across the two managers. In practice, Renovate publishes both from the same release pipeline, and the ci-images grouping rule ensures they land in the same MR regardless.

Config Validation in CI

The renovate:validate job runs renovate-config-validator when renovate.json5 changes in an MR or a push to the default branch:

# ci/projects/supply-chain.yml
renovate:validate:
  stage: build
  image: *renovate_image # shared anchor, same image as renovate:run
  environment:
    name: renovate
    action: access
  script:
    - test -s renovate.json5 || { echo "renovate.json5 missing or empty — check GIT_STRATEGY"; exit 1; }
    - renovate-config-validator renovate.json5
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      changes:
        - renovate.json5
    - if: $CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      changes:
        - renovate.json5
    - when: manual
      allow_failure: true
  timeout: 5m
  interruptible: true

Without this job, a broken renovate.json5 is caught only at the next scheduled renovate:run. If the weekly schedule fires and the config has a typo in a matchPackageNames regex or a malformed packageRules entry, Renovate fails silently. No MRs open. A week passes before anyone notices that dependency updates stopped. The validate job catches the same error in the MR that introduced it, within hours.

The two jobs use opposite GIT_STRATEGY values. renovate:run sets GIT_STRATEGY: none because Renovate clones the repo itself. renovate:validate leaves the default fetch because renovate-config-validator reads renovate.json5 from the workspace. Setting GIT_STRATEGY: none on the validate job produces a silent false positive: the runner skips the checkout, the workspace is empty, and renovate-config-validator reports “Config validated successfully” (exit 0) because it found nothing to check. The inline comment in the job documents this:

# Default GIT_STRATEGY (fetch) checks out the repo so the validator can read
# renovate.json5. Do NOT set GIT_STRATEGY: none here: with no checkout the
# workspace is empty and renovate-config-validator reports a false
# "Config validated successfully" (exit 0) because it finds nothing to check.

Tradeoff accepted: the validate job is cheap (5-minute timeout, cached image), but it introduces a failure mode that does not exist in the single-job pattern. Copying GIT_STRATEGY: none from the run job into the validate job is the natural thing to do, and it breaks silently. A test -s renovate.json5 guard before the validator command turns that silent false positive into a loud failure. The guard is in the snippet above.

What I’d Do Differently

These are remaining improvements. The environment: scoping, resource_group serialization, and the validate-job test -s guard are implemented; the items below are still open. All are compatible with GitLab Free.

Retire the PAT for a PrAT on a paid tier. The user PAT is the GitLab-Free reality, not the desired end state. If the repo moves to a tier where PrATs are available, swapping the token source removes the user-scoping and offboarding coupling. No CI change is needed; only the token differs.

Reconsider the orphan-branch isolation if the team grows. The in-repo pattern couples the Renovate job to the rest of the CI graph. For a single team where every CI change is reviewed in the same MR flow, this is fine. For a larger organization where the Renovate maintainer is not the same person reviewing application CI, the orphan-branch pattern gives the Renovate job its own lifecycle. No tier dependency.

References

Footnotes

  1. GitLab’s CI job token model allows cross-project privilege escalation when a central Renovate service has elevated access to multiple projects. The Renovate GitLab bot security advisory documents the attack and why no hosted GitLab App exists.

  2. The official renovate-runner project provides CI templates for self-hosting. The Renovate self-hosting docs point to it as the recommended starting point. 2

  3. Sigurd Spieckermann’s A secure and scalable Renovate service on GitLab is the most thorough published reference. It covers the privilege escalation exploit, the Project Access Token pattern, orphan-branch isolation, environment: scoping, approval gates for private dependencies, PrAT rotation, and commit signing with deploy keys. My setup is simpler and less hardened; read that post for the security-first approach. 2 3

  4. GitLab’s Project Access Tokens documentation states: “On GitLab.com, project access tokens require a Premium or Ultimate subscription. On GitLab Self-Managed and GitLab Dedicated, project access tokens are available with any license.” Check your tier before committing to a PrAT strategy.

  5. GitLab’s Project Access Tokens documentation scopes table: api grants complete read/write access to the API and is a strict superset of read_api (read-only), so listing both is redundant. Renovate needs write_repository to push branches and api to open MRs.

This post was written with AI assistance.