diff --git a/.github/workflows/build-nocache.yml b/.github/workflows/build-nocache.yml
index 5422a6deb..8a81c0aaf 100644
--- a/.github/workflows/build-nocache.yml
+++ b/.github/workflows/build-nocache.yml
@@ -13,6 +13,7 @@ jobs:
packages: write
env:
DOCKER_REPOSITORY: ghcr.io/dfe-digital/check-performance-data
+ WORKER_DOCKER_REPOSITORY: ghcr.io/dfe-digital/check-performance-data-worker
outputs:
docker-image-tag: ${{ steps.build-image.outputs.tag }}
runs-on: ubuntu-latest
@@ -42,6 +43,28 @@ jobs:
name: web
github-token: ${{ secrets.GITHUB_TOKEN }}
+ # The worker image installs curl, so it gets OS package fixes that the web image does not.
+ # Rebuild it here too, or its cached package layer stays old until its base digest changes.
+ - name: Build without cache and push worker docker image
+ id: build-worker-image
+ uses: DFE-Digital/github-actions/build-docker-image@master
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ context: ./src
+ dockerfile-path: ./src/DfE.CheckPerformanceData.RulesEngineWorker/Dockerfile
+ target: final
+ docker-repository: ${{ env.WORKER_DOCKER_REPOSITORY }}
+ max-cache: true
+ reuse-cache: false
+ snyk-token: ${{ secrets.SNYK_TOKEN }}
+
+ - name: SBOM for the worker image
+ uses: ./.github/actions/image-sbom
+ with:
+ image: ${{ steps.build-worker-image.outputs.image }}
+ name: worker
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+
- name: Notify teams channel on job failure
uses: DFE-Digital/github-actions/send-to-teams-channel@master
if: ${{ failure() }}
diff --git a/docs/dependabot.md b/docs/dependabot.md
index f6fd8832f..5159bec85 100644
--- a/docs/dependabot.md
+++ b/docs/dependabot.md
@@ -107,9 +107,31 @@ Dependabot's pull requests change the digest, and the tag if there is a newer on
republishes `10.0` with each .NET patch and Ubuntu security update.
Pinning the .NET images means the runtime patch an image ships changes only when a Dependabot
-pull request is merged, not at every build. `apt-get upgrade` in the Web and worker Dockerfiles
-still applies Ubuntu security updates at every build, and the weekly **Build No Cache** run still
-rebuilds the web image to pick them up.
+pull request is merged, not at every build. Ubuntu security updates come from `apt-get upgrade`
+in the Web and worker Dockerfiles (see [OS packages](#os-packages)).
+
+### OS packages
+
+The `base` stage of the Web and worker Dockerfiles runs `apt-get upgrade`. The worker also
+installs `curl`. This layer gets the Ubuntu security fixes that the base image does not have yet.
+
+CI reuses this layer from its build cache, so it does not run at each build. It runs again when:
+
+- a Dependabot pull request changes the base image digest;
+- the weekly **Build No Cache** run rebuilds the web and worker images on `main`;
+- the `OS_PACKAGES_DATE` build argument in the Dockerfile changes.
+
+When the Snyk scan in the **Build** job finds a high severity issue in an Ubuntu package, do these
+steps:
+
+1. Read the "Fixed in" version in the Snyk output. Snyk shows the image name after the issue
+ ("Target file").
+2. Make sure that Ubuntu has the fix. Run
+ `docker run --rm --user root sh -c "apt-get update -qq; apt-cache policy "`.
+3. If Ubuntu has the fix, set `OS_PACKAGES_DATE` to today's date in both Dockerfiles. Merge
+ the change to `main`, then merge `main` into the open pull requests.
+4. If Ubuntu does not have the fix ("Fixed in" is empty), the upgrade cannot help. Wait for a
+ new base image, or ask the team about a Snyk ignore with an end date.
### The pin check
diff --git a/docs/sbom.md b/docs/sbom.md
index 1246da618..d22f6667a 100644
--- a/docs/sbom.md
+++ b/docs/sbom.md
@@ -38,8 +38,8 @@ The action that does this is `.github/actions/image-sbom`, called after each ima
## Finding the SBOM for a deployed environment
Go by the image **digest** the running pods report, not by the sha tag: the weekly
-`Build No Cache` run rebuilds the web image and moves the sha tag to the new image without
-redeploying anything, so a running environment can be on an earlier digest than its tag.
+`Build No Cache` run rebuilds the web and worker images and moves each sha tag to the new image
+without redeploying anything, so a running environment can be on an earlier digest than its tag.
1. Read the digest from the pods (the namespace and deployment names are
`schoolstandards-development` / `schoolstandards-test` / `schoolstandards-production` and
@@ -89,7 +89,8 @@ Or scan the whole SBOM for known vulnerabilities with [grype](https://github.com
grype sbom:./sbom-web-.cdx.json
```
-The weekly `Build No Cache` run rebuilds the web image with current Ubuntu packages, pushes it
-under the same sha tag and attaches a new SBOM to the new image; the earlier image keeps its own.
+The weekly `Build No Cache` run rebuilds the web and worker images with current Ubuntu packages,
+pushes each under the same sha tag and attaches a new SBOM to the new image; the earlier image
+keeps its own.
Running pods stay on the digest they started with until the next deployment, which is why the
steps above start from the pod's digest rather than the tag.
diff --git a/src/DfE.CheckPerformanceData.RulesEngineWorker/Dockerfile b/src/DfE.CheckPerformanceData.RulesEngineWorker/Dockerfile
index 4d165f43b..6613fe5ea 100644
--- a/src/DfE.CheckPerformanceData.RulesEngineWorker/Dockerfile
+++ b/src/DfE.CheckPerformanceData.RulesEngineWorker/Dockerfile
@@ -3,6 +3,10 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0@sha256:de0ee358cdd672f7017a003bed21e9a
# `apt-get upgrade` applies Ubuntu security updates the base image has not yet
# been rebuilt with (see the Web Dockerfile for the CVE-2026-84782 example).
# curl is used by the container orchestrator's exec probe to query the HTTP health surface.
+# CI reuses this layer from its build cache, so the upgrade does not run at each build. To get
+# the current Ubuntu packages now, set OS_PACKAGES_DATE to today's date: a new value makes Docker
+# run the layer again (docs/dependabot.md#os-packages).
+ARG OS_PACKAGES_DATE=2026-10-29
RUN apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& apt-get install -y --no-install-recommends curl \
diff --git a/src/DfE.CheckPerformanceData.Web/Dockerfile b/src/DfE.CheckPerformanceData.Web/Dockerfile
index 09f485c1e..a59d1e08e 100644
--- a/src/DfE.CheckPerformanceData.Web/Dockerfile
+++ b/src/DfE.CheckPerformanceData.Web/Dockerfile
@@ -15,6 +15,10 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0@sha256:de0ee358cdd672f7017a003bed21e9a
# with published fixes outstanding (e.g. libssl3t64 3.0.13-0ubuntu3.15 against
# CVE-2026-84782), and the Snyk gate in CI then fails every build. Upgrading at
# build time keeps the shipped image at the current noble-security level.
+# CI reuses this layer from its build cache, so the upgrade does not run at each build. To get
+# the current Ubuntu packages now, set OS_PACKAGES_DATE to today's date: a new value makes Docker
+# run the layer again (docs/dependabot.md#os-packages).
+ARG OS_PACKAGES_DATE=2026-10-29
RUN apt-get update \
&& apt-get upgrade -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*