Automating Base Image Updates on GitHub Actions

A scheduled job resolves the new base image digest, opens a pull request, and lets your checks prove the update before the rebuild hits a feature branch.

Automate base image updates with a scheduled GitHub Actions job that resolves the digest a base tag currently points at, rewrites the pinned FROM line, and opens a pull request that your existing checks run against. The bump then arrives on a reviewable branch on a schedule you chose, and the cold rebuild it causes is paid once, in a window you expected.

This guide covers the failure modes of leaving base images to drift, the shape of the update job, the workflow YAML for a repository with several pinned bases, and a time model for the first build after a base change. Why the rebuild happens at all is covered in why a base image update invalidates a Docker build cache, and the term itself in base image.

Diagnosis

Five things go wrong, and only the first one is about caching.

An unpinned tag repoints on someone else's branch. FROM node:22-bookworm-slim resolves through a mutable tag, so the publisher can push new content behind it at any time. BuildKit keys each layer on its parent, and invalidating one layer invalidates every layer downstream of it, per the Docker cache invalidation reference, checked on 2026-08-13. The rebuild lands on whichever pull request happens to build first after the repoint, and the person who pays it changed one line of application code.

A pinned digest with no refresh job goes stale. Pinning fixes the first problem and creates a second one: the base image freezes, including its unpatched packages, until a commit moves the digest. A pin without a schedule is a decision to stop taking upstream updates.

The bump pull request arrives with no checks. This is the failure that quietly defeats the whole exercise. GitHub does not create workflow runs from events raised with the default GITHUB_TOKEN, documented in automatic token authentication, checked on 2026-08-13. A job that opens a pull request with that token produces a branch with an empty checks list, and the update merges with no proof that the image still builds.

Pins live in more than one file. A multi-stage Dockerfile carries one FROM per stage. Beyond it sit compose files, container jobs in workflow YAML, devcontainer definitions, and test fixtures. A refresh job that only reads Dockerfile leaves the rest of them frozen.

The schedule turns itself off. GitHub disables scheduled workflows in a repository after 60 days of no repository activity, per the events that trigger workflows reference, checked on 2026-08-13. A quiet service repository is exactly the one whose base image nobody is watching.

Fix

The job that solves all five is small, and its four steps map to the four failures above.

  1. Resolve the digest the tag points at today. One registry query per pinned image, run on a cron rather than on a push.
  2. Rewrite the pin in place. One pull request per base image, so a bump that breaks a build names its own culprit.
  3. Open it with a token that triggers workflows. A GitHub App installation token or a personal access token, so the image build runs on the bump branch and the cold rebuild is paid there.
  4. Merge on green. Auto-merge on the bump label turns a passing image build into a merged commit without a person in the loop, while a failing one stays open for review.

Two rules make the result predictable. Pin every base image by digest with the tag left in the line as a readable label, so the digest is what resolves and the tag is what a reader recognizes. And run the update job for every file that carries a pin, using the matrix in the next section, because the Dockerfile is rarely the only one.

Configuration

Where the pins live

Pin locationReference formatRewrite tool in the job
FROM in a Dockerfilenode:22-bookworm-slim@sha256:...sed on the FROM line
A second or third stage FROMits own tag and digestone matrix entry per stage
image: in a compose filetag, digest optionalyq
container.image in a workflow jobtagyq on the workflow file
image in devcontainer.jsontagjq

Resolving the digest

CommandRequiresReturns
docker buildx imagetools inspect node:22 --format '{{.Manifest.Digest}}'buildxindex digest for a multi-architecture tag, which is the reference to pin
crane digest node:22the crane CLIsame digest with no Docker daemon
docker manifest inspect -v node:22Docker CLIfull manifest list, per-platform digests included

Pin the index digest rather than a per-platform manifest digest. The index resolves to the right manifest on both linux/amd64 and linux/arm64, which matters on multi-architecture builds, and it is the value the first two commands return by default.

The scheduled update workflow

.github/workflows/base-image-update.yml
name: base-image-update

on:
  schedule:
    - cron: "0 6 * * 1"
  workflow_dispatch:

permissions:
  contents: read

jobs:
  bump:
    runs-on: warp-ubuntu-latest-x64-2x
    permissions:
      contents: write
      pull-requests: write
    strategy:
      fail-fast: false
      matrix:
        include:
          - image: node:22-bookworm-slim
            file: Dockerfile
            branch: chore/base-node-22
          - image: ubuntu:24.04
            file: tools/Dockerfile.ci
            branch: chore/base-ubuntu-2404
    steps:
      - uses: actions/checkout@v4

      - id: token
        uses: actions/create-github-app-token@v1
        with:
          app-id: ${{ vars.BUMP_APP_ID }}
          private-key: ${{ secrets.BUMP_APP_PRIVATE_KEY }}

      - id: resolve
        env:
          IMAGE: ${{ matrix.image }}
        run: |
          digest=$(docker buildx imagetools inspect "$IMAGE" \
            --format '{{.Manifest.Digest}}')
          echo "digest=$digest" >> "$GITHUB_OUTPUT"

      - id: rewrite
        env:
          IMAGE: ${{ matrix.image }}
          FILE: ${{ matrix.file }}
          DIGEST: ${{ steps.resolve.outputs.digest }}
        run: |
          sed -i -E \
            "s|^FROM ${IMAGE}(@sha256:[0-9a-f]{64})?|FROM ${IMAGE}@${DIGEST}|" \
            "$FILE"
          if git diff --quiet -- "$FILE"; then
            echo "changed=false" >> "$GITHUB_OUTPUT"
          else
            echo "changed=true" >> "$GITHUB_OUTPUT"
          fi

      - if: steps.rewrite.outputs.changed == 'true'
        uses: peter-evans/create-pull-request@v7
        with:
          token: ${{ steps.token.outputs.token }}
          branch: ${{ matrix.branch }}
          labels: base-image-update
          commit-message: "chore: pin ${{ matrix.image }} to ${{ steps.resolve.outputs.digest }}"
          title: "Bump ${{ matrix.image }}"
          body: |
            Scheduled refresh of `${{ matrix.image }}` in `${{ matrix.file }}`.

            Merging rebuilds every layer above FROM. The rebuild is paid on this
            branch by the image build check below.

fail-fast: false keeps one unreachable registry from cancelling the other bumps. The digest step is a registry read, so the 2 vCPU size is the right host for it, and a week where nothing moved produces a run of a few seconds per matrix leg with no pull request, since create-pull-request opens nothing when the tree is unchanged.

Let the checks prove it, then merge

The bump branch has to run the same image build a human branch runs. With the App token above, pull_request fires normally, so no extra wiring is needed beyond your existing workflow. Add auto-merge on the label so a green run does not wait for a reviewer:

.github/workflows/base-image-automerge.yml
name: base-image-automerge

on:
  pull_request_target:
    types: [labeled]

jobs:
  enable:
    if: github.event.label.name == 'base-image-update'
    runs-on: warp-ubuntu-latest-x64-2x
    permissions:
      pull-requests: write
      contents: write
    steps:
      - run: gh pr merge --squash --auto "$PR_URL"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          PR_URL: ${{ github.event.pull_request.html_url }}

Auto-merge waits for the required checks to pass before it merges, per automatically merging a pull request, checked on 2026-08-13, so a base image that breaks the build leaves the pull request open instead.

If your pins all sit in Dockerfiles, the docker package ecosystem in Dependabot's options reference, checked on 2026-08-13, does the resolve and rewrite for you, and dependency update jobs on GitHub Actions covers how to keep those pull requests off the full matrix. The bespoke job earns its place when pins live outside those files or when you want every bump to land in one window.

Where the rebuild lands after the merge

The rebuild is fixed work, and the variable is how many separate caches pay for it. A WarpBuild builder profile maps to one dedicated build virtual machine that keeps its layer cache on local disk and shares it across every job pointed at that profile, per the remote Docker builders documentation, checked on 2026-08-13. One build after the merge repopulates the chain and the jobs behind it read warm layers, so the cold rebuild count tracks digest changes rather than branch count. Point a small job at the profile on pushes to the default branch to make that first rebuild deterministic:

.github/workflows/warm-builder.yml
name: warm-builder

on:
  push:
    branches: [main]
    paths: [Dockerfile, tools/Dockerfile.ci]

concurrency:
  group: warm-builder
  cancel-in-progress: true

jobs:
  warm:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - uses: Warpbuilds/build-push-action@v6
        with:
          context: .
          push: false
          tags: acme/api:warm
          profile-name: api-amd64

Dependency caches outside the Docker layer chain are a separate lever, and the drop-in replacement for actions/cache is in the WarpBuild caching documentation.

Cost or Time Model

The model is arithmetic on stated assumptions rather than a measurement. Replace the rows with the step durations in your own build log.

Assumptions

  • A Node service image, two stages, both on node:22-bookworm-slim, 1,400 npm packages.
  • Six pinned base images across the repository, refreshed by the weekly cron above.
  • On average 2.5 of those six digests move in a given week, which is 10 base changes a month.
  • Job runner warp-ubuntu-latest-x64-4x at $0.008 per minute and a 16 vCPU, 32 GB builder profile at $0.06 per minute, both from the pricing page, checked on 2026-08-13.

The first build after a base change

StepWarmFirst build after the bump
Checkout, registry login, action setup0:500:50
Pull the new base layers, both stages0:000:35
apt-get install build dependencies0:001:20
npm ci, 1,400 packages0:003:05
Compile the application bundle1:301:45
COPY application source0:050:05
Export and push changed layers0:352:40
Total3:0010:20

One base change costs 7 minutes 20 seconds of extra wall clock and 7.33 extra minutes on both the runner and the builder, which is 7.33 at $0.068 combined, or $0.50.

A month of staying current

Each base change is paid twice on this setup: once on the bump pull request, where the check proves the image still builds, and once by the warm job on the default branch after the merge. Every feature branch that builds afterwards reads the warm cache.

LineValue
Base changes per month10
Cold builds per change2
Extra minutes over a warm build146.7
Extra runner cost at $0.008$1.17
Extra builder cost at $0.06$8.80
Extra cost per month$9.97

Ten dollars a month, on these assumptions, is what it costs to know the age of every base image in the repository and to have every rebuild land on a branch created for it. The comparison that matters is time rather than dollars. The same 10 minute 20 second wait otherwise lands, without warning, on the next person to open a pull request after upstream repoints a tag.

Two levers move the total. A daily cron finds moving digests sooner and pays more cold rebuilds, since a base that moves three times in a week is billed three times rather than once. And a shared builder profile keeps the second number at one rebuild per change; per-branch cache scopes multiply it by the number of active branches.

Rates used

ResourceShapePrice per minute
warp-ubuntu-latest-x64-2x2 vCPU, 8 GB$0.004
warp-ubuntu-latest-x64-4x4 vCPU, 16 GB$0.008
warp-ubuntu-latest-x64-8x8 vCPU, 32 GB$0.016
Builder profile16 vCPU, 32 GB, 100GB disk$0.06
Builder profile32 vCPU, 64 GB, 200GB disk$0.12

The runner and the builder are billed separately, so a job that offloads the build to a builder profile pays a small runner rate for the orchestrating job and the builder rate for the build itself. Linux runners are the usual host for a Docker build job. Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the product surface. The rest of the pipeline is covered in the Docker builds on GitHub Actions hub.

FAQ

Why does my base image bump pull request show no checks?

GitHub does not start workflow runs from events created with the default GITHUB_TOKEN, so a pull request opened by a job holding that token arrives with an empty checks list. Give the job a GitHub App installation token or a personal access token instead, and the pull_request event fires normally and your image build runs on the bump branch.

How often should the base image update job run?

Weekly is the usual setting because it bounds the age of the base image at seven days while producing at most one bump pull request per base per week. A daily cron finds new digests sooner and pays a full cold rebuild each time one moves. Whichever cadence you pick, keep the repository active, since GitHub disables scheduled workflows after 60 days without repository activity.

Do I still need this if Dependabot already updates my Dockerfile?

Dependabot's docker ecosystem bumps tags and digests in Dockerfiles, and for most repositories that covers the FROM lines. A bespoke job is worth writing when pins live outside the files Dependabot reads, such as a compose file, a container job in a workflow, or a devcontainer definition, or when you want the rebuild scheduled into one window rather than arriving whenever the bot opens a pull request.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.