~ / posts /

Stop Letting Your Docker Base Images Rot: Automated Tag Pinning with Renovate

Stop Letting Your Docker Base Images Rot: Automated Tag Pinning with Renovate

You pushed a fix at 3 PM on a Friday. CI passed. Deployment succeeded. By 6 PM, production is broken — not because of your code, but because the upstream node:20-alpine image was silently updated two hours before your build ran, pulling in a musl libc patch that changed the behavior of a native module you depend on. You weren’t even tracking it. You had no idea.

This is the latest tag trap, and it’s more common than anyone admits. Even teams that religiously pin application dependencies via package-lock.json, go.sum, or requirements.txt routinely leave their base images floating. Renovate’s Docker datasource — and the pattern it enforces — is the fix.

The Problem With Floating Base Images

Docker’s layer caching gives a false sense of security. You pin FROM node:20-alpine and assume it’s stable. It’s not. Docker image tags are mutable references. The image digest behind node:20-alpine changes every time the upstream maintainers push a security patch, runtime update, or dependency bump. Your tag is pinned; your build is not.

The failure modes range from annoying to catastrophic:

  • Silent behavioral changes: a patched openssl or musl version changes TLS handshake behavior
  • Missing binaries: a trimmed base layer removes a tool your RUN step depends on
  • ABI breaks: a bumped libc version breaks native Node addons or Go CGO binaries
  • Reproducibility collapse: two identical docker build runs on different days produce different images

The correct fix is digest pinning:

# Bad -- mutable
FROM node:20-alpine

# Better -- immutable
FROM node:20-alpine@sha256:a1b2c3d4e5f6...

Digest-pinned images are reproducible. They’re also immediately stale — which brings us to the actual problem: pinned images need to be updated, and doing that manually is a tax no team actually pays.

Visualizing the Docker image tag resolution chain and digest mismatch scenario

Why Renovate’s Docker Datasource Changes the Equation

Renovate is usually framed as a JavaScript or Python dependency updater. Its Docker support is underappreciated. Renovate’s Docker datasource is mature and stable — it treats FROM directives in Dockerfiles, image references in docker-compose.yml, and even custom patterns in arbitrary files as first-class dependency sources.

The recent 43.89.7 patch — which bumps the ghcr.io/renovatebot/base-image tag to v13.28.8 — is a meta-example of this: Renovate itself uses Renovate to keep its own base image current. It’s eating its own dog food at scale across hundreds of Renovate bot deployments globally.

The implication is significant. If Renovate’s internal infrastructure is managed this way, the pattern is mature enough for production.

flowchart TD
    A[Docker Registry\nGHCR / DockerHub] -->|New digest published| B[Renovate Scheduler]
    B --> C{Config Rules\nMatch?}
    C -->|Yes| D[Open PR with\ndigest update]
    C -->|No| E[Skip / Log]
    D --> F[CI Pipeline\nRuns Tests]
    F -->|Pass| G[Auto-merge or\nManual Review]
    F -->|Fail| H[PR stays open\nNotify team]
    G --> I[Updated Dockerfile\nin main branch]

Setting Up Renovate for Docker Image Tracking

Assuming you’re self-hosting Renovate (via the official Docker image, naturally), here’s a renovate.json config that handles the common cases:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended"],
  "pinDigests": true,
  "packageRules": [
    {
      "matchDatasources": ["docker"],
      "matchPackageNames": ["node/**", "python/**", "golang/**"],
      "groupName": "runtime base images",
      "schedule": ["before 6am on Monday"],
      "automerge": false
    },
    {
      "matchDatasources": ["docker"],
      "matchPackageNames": ["alpine/**", "debian/**", "ubuntu/**"],
      "groupName": "OS base images",
      "schedule": ["before 6am on the first day of the month"],
      "automerge": false
    },
    {
      "matchDatasources": ["docker"],
      "matchPackageNames": ["renovate/**"],
      "automerge": true,
      "automergeType": "pr"
    }
  ]
}

The key settings:

  • pinDigests: true — Renovate will rewrite your FROM node:20-alpine to include the SHA256 digest and then manage updates to that digest
  • schedule — base image updates don’t need daily noise; weekly for runtimes, monthly for OS layers is usually right
  • automerge: false for runtime images — a patched node:20 can still break native modules; require human sign-off
  • automergeType: "pr" for low-risk images (like the Renovate bot itself) — this creates a PR that auto-merges after CI passes, rather than the more aggressive "branch" mode which pushes directly to the base branch without creating a PR at all

What Renovate Does to Your Dockerfile

Before:

FROM node:20-alpine

After Renovate’s first run:

FROM node:20-alpine@sha256:1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890ab

When node:20-alpine gets a new digest:

FROM node:20-alpine@sha256:9f8e7d6c5b4a321098765432fedcba9876543210fedcba9876543210fedcba9876

The PR title will be something like: chore(deps): update docker tag node to 20-alpine@sha256:9f8e7.... Your CI runs against the new image. If it fails, the PR sits open and alerts you. If it passes, you merge with confidence.

Multi-Stage Builds Need Per-Stage Tracking

Multi-stage Dockerfiles are where this gets subtle. Each FROM is a separate base image, and they can drift independently:

# Build stage
FROM golang:1.24-alpine@sha256:aaaa... AS builder
WORKDIR /app
COPY . .
RUN go build -o server .

# Runtime stage
FROM gcr.io/distroless/static-debian12@sha256:bbbb...
COPY --from=builder /app/server /server
ENTRYPOINT ["/server"]

Renovate handles each FROM independently. The golang:1.24-alpine build image and the distroless/static-debian12 runtime image get separate PRs on their own schedules. This is correct — they have different risk profiles. A build toolchain update breaking your go build step is annoying but safe; a runtime base image change breaking your server in prod is not.

stateDiagram-v2
    [*] --> Pinned: Renovate rewrites Dockerfile
    Pinned --> UpstreamChanged: Registry publishes new digest
    UpstreamChanged --> PROpen: Renovate opens PR
    PROpen --> CIRunning: CI triggered
    CIRunning --> CIFailed: Tests fail
    CIRunning --> CIPassed: Tests pass
    CIFailed --> PRBlocked: PR stays open, team notified
    CIPassed --> Merged: Manual merge or automerge
    Merged --> Pinned: New digest committed
    PRBlocked --> [*]: Human investigates

Handling Private Registries

If you’re pulling from a private registry (Harbor, ECR, GHCR with private repos), you need to give Renovate credentials. For self-hosted Renovate, the hostRules config handles this:

{
  "hostRules": [
    {
      "matchHost": "ghcr.io",
      "username": "your-bot-account",
      "password": "{{ secrets.GHCR_TOKEN }}"
    },
    {
      "matchHost": "123456789.dkr.ecr.us-east-1.amazonaws.com",
      "username": "AWS",
      "password": "{{ secrets.ECR_TOKEN }}"
    }
  ]
}

For ECR specifically, the token rotates every 12 hours. Either use a Lambda to refresh it or use the ecr-credential-helper pattern. There’s a known rough edge here: Renovate’s ECR support works, but the token refresh lifecycle is on you.

Practical Benchmark: Manual vs Automated Image Management

MetricManual (no pinning)Manual (digest pinning)Renovate automated
Build reproducibilityNeverAlwaysAlways
Time to detect upstream changeDays-weeksNever (no detection)Hours (next schedule)
Security patch latencyWeeks-monthsN/ADays (auto PR)
PR noise per month004-8 (batched)
Risk of surprise breakageHighZero (frozen)Low (CI-gated)
Maintenance burdenLow (until it breaks)High (manual update loop)Near-zero

The Manual (digest pinning) column is the worst outcome: you get the reproducibility but create a maintenance black hole. Images never update, security patches pile up, and when you do eventually update (because something finally breaks), you’re dealing with 6 months of upstream changes at once.

Renovate closes this loop. Small, frequent, CI-validated updates are operationally far safer than infrequent large ones.

When NOT to Use Digest Pinning

Air-gapped environments: If you’re mirroring images to an internal registry and you don’t control the digest synchronization pipeline, pinning to upstream digests will break your builds when the internal mirror has a different digest for the same tag. Pin to your internal tag instead and manage the mirror separately.

Shared base images you own: If your team publishes a common mycompany/base:1.2 image and multiple services consume it, Renovate will open 40 PRs when you bump it. Either use a monorepo, coordinate your base image versioning, or configure automerge: true with a reliable test suite.

Rapidly iterating pre-release work: During active development on a feature branch, digest update PRs add noise. Scope Renovate to specific base branches so it ignores transient feature work:

{
  "baseBranches": ["main", "release/*"]
}

The ghcr.io/renovatebot/base-image Pattern

The 43.89.7 release is worth calling out specifically because it illustrates a pattern worth copying: Renovate’s own Dockerfile pins to ghcr.io/renovatebot/base-image with a digest, and Renovate’s own CI opens a PR whenever that image updates. The release changelog entry — “update ghcr.io/renovatebot/base-image docker tag to v13.28.8” — is generated by the same tool being updated.

This closed-loop, self-managing pattern is what production Docker image hygiene looks like at scale. You can replicate it for your own internal base images by:

  1. Publishing your internal base image to a registry Renovate can reach
  2. Adding your base image as a packageRule with appropriate scheduling
  3. Running Renovate against your consumer repos on a schedule

The result is a fully automated, CI-gated pipeline where no base image ever silently drifts.

flowchart LR
    subgraph Internal
        A[Base Image\nCI Pipeline] -->|Publishes v1.2.3| B[Internal Registry\nHarbor / ECR]
        B --> C[Renovate\nScheduler]
        C -->|Opens PRs| D[Service Repo A]
        C -->|Opens PRs| E[Service Repo B]
        C -->|Opens PRs| F[Service Repo C]
    end
    subgraph External
        G[DockerHub\nGHCR] -->|New digest| C
    end

Renovate PR showing digest update with changelog diff and CI status checks

Wiring It Into Your Workflow

The last operational piece is where Renovate runs. For GitHub Actions:

# .github/workflows/renovate.yml
name: Renovate

on:
  schedule:
    - cron: '0 3 * * 1'  # Monday 3 AM UTC
  workflow_dispatch:

jobs:
  renovate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: renovatebot/github-action@v41
        with:
          configurationFile: renovate.json
          token: ${{ secrets.RENOVATE_TOKEN }}
        env:
          LOG_LEVEL: info  # Use 'debug' only for troubleshooting -- it generates substantial output

The RENOVATE_TOKEN needs repo scope to open PRs and read:packages if you’re tracking GHCR images. Keep it scoped — don’t use your personal PAT.


Letting base images drift is technical debt with an unpredictable interest rate. It compounds silently and cashes out at the worst possible moment — usually a Friday evening. Renovate’s Docker datasource, combined with digest pinning, converts that unpredictable tail risk into a boring, scheduled, CI-validated maintenance routine. The Renovate project using this pattern on its own base image — as seen in the 43.89.7 release — is a proof of concept running in production across thousands of deployments. Copy the pattern.