From f6a67049a6275bf3d50503283e86c19fbfb0a493 Mon Sep 17 00:00:00 2001 From: John Watson Date: Fri, 14 Aug 2026 12:33:56 +0100 Subject: [PATCH 1/8] Bump DEFRA_VERSION, harden example Dockerfiles, fix Sonar findings, document SBOM --- Dockerfile | 8 ++++---- JOB.env | 2 +- README.md | 18 ++++++++++++++++++ examples/Dockerfile.service | 17 +++++++++++++---- examples/Dockerfile.web | 21 ++++++++++++++------- 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/Dockerfile b/Dockerfile index e592183..ad5da95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Set default values for build arguments -ARG DEFRA_VERSION=3.1.3 +ARG DEFRA_VERSION=3.1.4 ARG BASE_VERSION=24.19.0-alpine3.24 ARG NPM_VERSION=12.0.2 @@ -16,13 +16,13 @@ ENV NPM_CONFIG_PREFIX=/home/node/.npm-global ENV PATH=$PATH:/home/node/.npm-global/bin ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/internal-ca.crt -RUN apk add --no-cache tini ca-certificates - # Upgrade the bundled npm CLI to a pinned version to clear vulnerabilities in the # libraries npm ships with. Target /usr/local explicitly so the base image's own npm # is replaced rather than a second copy installed under NPM_CONFIG_PREFIX. # NPM_VERSION is kept current by the auto-update workflow. -RUN npm install -g --prefix=/usr/local npm@${NPM_VERSION} && npm cache clean --force +RUN apk add --no-cache tini ca-certificates \ + && npm install -g --ignore-scripts --prefix=/usr/local "npm@${NPM_VERSION}" \ + && npm cache clean --force # Install Internal CA certificate for firewall and Zscaler proxy COPY certificates/internal-ca.crt /usr/local/share/ca-certificates/internal-ca.crt diff --git a/JOB.env b/JOB.env index f76c9d3..c69962e 100644 --- a/JOB.env +++ b/JOB.env @@ -1,3 +1,3 @@ -DEFRA_VERSION=3.1.3 +DEFRA_VERSION=3.1.4 IMAGE_NAME=node NPM_VERSION=12.0.2 diff --git a/README.md b/README.md index 1f76024..d22c2e9 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,24 @@ A build is only blocked by vulnerabilities that have a fix available, so unpatch For more details see [Image Scanning](IMAGE_SCANNING.md) +## Software Bill of Materials (SBOM) + +On every push to `main`, each production image variant has an SBOM generated from its actual container contents using [Syft](https://github.com/anchore/syft) (via [anchore/sbom-action](https://github.com/anchore/sbom-action)), which is: + +- submitted to this repository's [Dependency graph](../../network/dependencies), so vulnerable OS packages and runtime dependencies show up alongside Dependabot alerts, and +- uploaded as a downloadable workflow artifact for that run. + +The image pushed to Docker Hub also carries the same SBOM as a build attestation (`docker buildx build --sbom=true`). You can inspect it directly from the published image without pulling it: + +``` +docker buildx imagetools inspect defradigital/node: --format '{{json (index .SBOM "linux/amd64").SPDX}}' +``` + +Known limitations: + +- Alpine/APK packages are listed under GitHub's generic "Other" ecosystem in the Dependency graph. This isn't configurable on our side — GitHub has no dedicated APK/Alpine ecosystem, unlike npm or NuGet. +- If a Node.js version is ever dropped from [image-matrix.json](image-matrix.json) (as has happened previously for other major versions across our images), its Dependency graph entry and its last-published Docker Hub tag both freeze at their final state permanently. Neither is deleted automatically; there is no API to expire a Dependency graph snapshot. + ## Automated version updates The [auto-update](/.github/workflows/auto-update.yml) workflow runs nightly. It checks for new releases of Node.js (and their Alpine images) and of the npm CLI, and when it finds one it opens a pull request that bumps the affected versions across the [image-matrix.json](image-matrix.json), [JOB.env](JOB.env), [Dockerfile](Dockerfile), [README.md](README.md) and the [examples](examples). diff --git a/examples/Dockerfile.service b/examples/Dockerfile.service index ac51204..96bfdc2 100644 --- a/examples/Dockerfile.service +++ b/examples/Dockerfile.service @@ -1,14 +1,12 @@ # This assumes that the parent image has been built locally using production and development build configuration as defra-node # and defra-node-development tagged with a version. -ARG BASE_VERSION=3.1.3-node24.19.0 +ARG BASE_VERSION=3.1.4-node24.19.0 FROM defra-node:$BASE_VERSION AS base # Copy our package files so that our package install will do a clean install. This installs the exact versions of the packages # listed in package-lock.json, and does not update either the package-lock.json or the package.json file. -# Our production dependencies are now installed. COPY --chown=node:node package*.json ./ -RUN npm ci --ignore-scripts # Development stage installs devDependencies, builds app from source and declares a file watcher as the default command. # We name this stage so we can refer to it in later stages @@ -21,7 +19,7 @@ EXPOSE 9229 9230 COPY --from=base --chown=node:node /home/node/package*.json ./ # We run a full dev install to bring in any development packages -RUN npm install --production=false --ignore-scripts +RUN npm ci --ignore-scripts COPY --chown=node:node app/ ./app/ # We specify a command here, as we can run this stage directly using either docker-compose files, or passing --target to the docker build command @@ -39,6 +37,17 @@ CMD [ "npm", "run", "test" ] # Production stage exposes service port, copies in built app code and declares the Node app as the default command FROM base AS production +# Switch to root temporarily to install production dependencies and lock down file permissions. +USER root + +# Copy application artifacts and assign root ownership to prevent modification by other users. +COPY --from=development --chown=root:root /home/node/package*.json ./ +COPY --from=development --chown=root:root /home/node/app/ ./app/ + +# Install node modules, remove write permissions, then drop back to the unprivileged user. +RUN npm ci --ignore-scripts --omit=dev && chmod -R a-w /home/node +USER node + # This is the command that is run for the production service. The parent image has an ENTRYPOINT that uses a lightweight # init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and # orphaned processes diff --git a/examples/Dockerfile.web b/examples/Dockerfile.web index 1cb9b04..8c803d1 100644 --- a/examples/Dockerfile.web +++ b/examples/Dockerfile.web @@ -1,7 +1,7 @@ # This assumes that the parent image has been built locally using production and development build configuration as defra-node # and defra-node-development tagged with a version. -ARG BASE_VERSION=3.1.3-node24.19.0 +ARG BASE_VERSION=3.1.4-node24.19.0 FROM defra-node:$BASE_VERSION AS base # Set the port that is going to be exposed later on in the Dockerfile as well. @@ -10,9 +10,7 @@ ENV PORT=${PORT} # Copy our package files so that our package install will do a clean install. This installs the exact versions of the packages # listed in package-lock.json, and does not update either the package-lock.json or the package.json file. -# Our production dependencies are now installed. COPY --chown=node:node package*.json ./ -RUN npm ci --ignore-scripts # Development stage installs devDependencies, builds app from source and declares a file watcher as the default command. # We name this stage so we can refer to it in later stages @@ -25,7 +23,7 @@ EXPOSE ${PORT} 9229 COPY --from=base --chown=node:node /home/node/package*.json ./ # We run a full dev install to bring in any development packages -RUN npm install --production=false --ignore-scripts +RUN npm ci --ignore-scripts COPY --chown=node:node app/ ./app/ # Run the build command to get the extra files needed for production. We also specify a command here, @@ -52,9 +50,18 @@ WORKDIR /home/node # Expose the PORT passed in at the start of the file EXPOSE ${PORT} -# Copy in the files that we built using the tools in the development stage. The final production stage will have the built files, -# but none of the tools required to build those files. This reduces the attack surface, and also the size of the final production image -COPY --from=development /home/node/app/ ./app/ +# Switch to root temporarily to install production dependencies and lock down file permissions. +USER root + +# Copy in the files that we built using the tools in the development stage, assigning root +# ownership to prevent modification by other users. This reduces the attack surface, and +# also the size of the final production image. +COPY --from=development --chown=root:root /home/node/package*.json ./ +COPY --from=development --chown=root:root /home/node/app/ ./app/ + +# Install node modules, remove write permissions, then drop back to the unprivileged user. +RUN npm ci --ignore-scripts --omit=dev && chmod -R a-w /home/node +USER node # This is the command that is run for the production service. The parent image has an ENTRYPOINT that uses a lightweight # init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and From 305af96fe97c8fd8e3968215d15b5c8701c01928 Mon Sep 17 00:00:00 2001 From: John Watson Date: Fri, 14 Aug 2026 12:44:20 +0100 Subject: [PATCH 2/8] Align examples with updated Docker guidance (software-development-standards#140) --- examples/.dockerignore | 9 +++++++++ examples/Dockerfile.service | 16 +++++++++------- examples/Dockerfile.web | 16 +++++++++------- 3 files changed, 27 insertions(+), 14 deletions(-) create mode 100644 examples/.dockerignore diff --git a/examples/.dockerignore b/examples/.dockerignore new file mode 100644 index 0000000..5ccab37 --- /dev/null +++ b/examples/.dockerignore @@ -0,0 +1,9 @@ +node_modules +Dockerfile +.dockerignore +.git +.env +coverage +**/*.test.js +LICENCE +README.md diff --git a/examples/Dockerfile.service b/examples/Dockerfile.service index 96bfdc2..a7617a8 100644 --- a/examples/Dockerfile.service +++ b/examples/Dockerfile.service @@ -1,8 +1,10 @@ -# This assumes that the parent image has been built locally using production and development build configuration as defra-node -# and defra-node-development tagged with a version. +# This uses the published Defra Node.js parent images from Docker Hub: +# https://github.com/DEFRA/defra-docker-node ARG BASE_VERSION=3.1.4-node24.19.0 -FROM defra-node:$BASE_VERSION AS base +FROM defradigital/node:$BASE_VERSION AS base + +ENV TZ="Europe/London" # Copy our package files so that our package install will do a clean install. This installs the exact versions of the packages # listed in package-lock.json, and does not update either the package-lock.json or the package.json file. @@ -10,7 +12,7 @@ COPY --chown=node:node package*.json ./ # Development stage installs devDependencies, builds app from source and declares a file watcher as the default command. # We name this stage so we can refer to it in later stages -FROM defra-node-development:$BASE_VERSION AS development +FROM defradigital/node-development:$BASE_VERSION AS development # Expose some development debugging ports that are used during development EXPOSE 9229 9230 @@ -20,7 +22,7 @@ COPY --from=base --chown=node:node /home/node/package*.json ./ # We run a full dev install to bring in any development packages RUN npm ci --ignore-scripts -COPY --chown=node:node app/ ./app/ +COPY --chown=node:node src/ ./src/ # We specify a command here, as we can run this stage directly using either docker-compose files, or passing --target to the docker build command CMD [ "npm", "run", "start:watch" ] @@ -42,7 +44,7 @@ USER root # Copy application artifacts and assign root ownership to prevent modification by other users. COPY --from=development --chown=root:root /home/node/package*.json ./ -COPY --from=development --chown=root:root /home/node/app/ ./app/ +COPY --from=development --chown=root:root /home/node/src/ ./src/ # Install node modules, remove write permissions, then drop back to the unprivileged user. RUN npm ci --ignore-scripts --omit=dev && chmod -R a-w /home/node @@ -51,4 +53,4 @@ USER node # This is the command that is run for the production service. The parent image has an ENTRYPOINT that uses a lightweight # init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and # orphaned processes -CMD [ "node", "app/index" ] +CMD [ "node", "src/index" ] diff --git a/examples/Dockerfile.web b/examples/Dockerfile.web index 8c803d1..45f59d3 100644 --- a/examples/Dockerfile.web +++ b/examples/Dockerfile.web @@ -1,8 +1,10 @@ -# This assumes that the parent image has been built locally using production and development build configuration as defra-node -# and defra-node-development tagged with a version. +# This uses the published Defra Node.js parent images from Docker Hub: +# https://github.com/DEFRA/defra-docker-node ARG BASE_VERSION=3.1.4-node24.19.0 -FROM defra-node:$BASE_VERSION AS base +FROM defradigital/node:$BASE_VERSION AS base + +ENV TZ="Europe/London" # Set the port that is going to be exposed later on in the Dockerfile as well. ARG PORT=3000 @@ -14,7 +16,7 @@ COPY --chown=node:node package*.json ./ # Development stage installs devDependencies, builds app from source and declares a file watcher as the default command. # We name this stage so we can refer to it in later stages -FROM defra-node-development:$BASE_VERSION AS development +FROM defradigital/node-development:$BASE_VERSION AS development # Expose the PORT passed in to the Dockerfile, and also some development debugging ports that are used during development EXPOSE ${PORT} 9229 @@ -24,7 +26,7 @@ COPY --from=base --chown=node:node /home/node/package*.json ./ # We run a full dev install to bring in any development packages RUN npm ci --ignore-scripts -COPY --chown=node:node app/ ./app/ +COPY --chown=node:node src/ ./src/ # Run the build command to get the extra files needed for production. We also specify a command here, # as we can run this stage directly using either docker-compose files, or passing --target to the docker build command @@ -57,7 +59,7 @@ USER root # ownership to prevent modification by other users. This reduces the attack surface, and # also the size of the final production image. COPY --from=development --chown=root:root /home/node/package*.json ./ -COPY --from=development --chown=root:root /home/node/app/ ./app/ +COPY --from=development --chown=root:root /home/node/src/ ./src/ # Install node modules, remove write permissions, then drop back to the unprivileged user. RUN npm ci --ignore-scripts --omit=dev && chmod -R a-w /home/node @@ -66,4 +68,4 @@ USER node # This is the command that is run for the production service. The parent image has an ENTRYPOINT that uses a lightweight # init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and # orphaned processes -CMD [ "node", "app/index" ] +CMD [ "node", "src/index" ] From 66bf13b57ed9c9db8f57641c65a999e11992d876 Mon Sep 17 00:00:00 2001 From: John Watson Date: Fri, 14 Aug 2026 12:44:39 +0100 Subject: [PATCH 3/8] Simplify SBOM README wording --- README.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/README.md b/README.md index d22c2e9..ece9ca8 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ For more details see [Image Scanning](IMAGE_SCANNING.md) On every push to `main`, each production image variant has an SBOM generated from its actual container contents using [Syft](https://github.com/anchore/syft) (via [anchore/sbom-action](https://github.com/anchore/sbom-action)), which is: -- submitted to this repository's [Dependency graph](../../network/dependencies), so vulnerable OS packages and runtime dependencies show up alongside Dependabot alerts, and +- submitted to this repository's Dependency graph, so vulnerable OS packages and runtime dependencies show up alongside Dependabot alerts, and - uploaded as a downloadable workflow artifact for that run. The image pushed to Docker Hub also carries the same SBOM as a build attestation (`docker buildx build --sbom=true`). You can inspect it directly from the published image without pulling it: @@ -77,11 +77,6 @@ The image pushed to Docker Hub also carries the same SBOM as a build attestation docker buildx imagetools inspect defradigital/node: --format '{{json (index .SBOM "linux/amd64").SPDX}}' ``` -Known limitations: - -- Alpine/APK packages are listed under GitHub's generic "Other" ecosystem in the Dependency graph. This isn't configurable on our side — GitHub has no dedicated APK/Alpine ecosystem, unlike npm or NuGet. -- If a Node.js version is ever dropped from [image-matrix.json](image-matrix.json) (as has happened previously for other major versions across our images), its Dependency graph entry and its last-published Docker Hub tag both freeze at their final state permanently. Neither is deleted automatically; there is no API to expire a Dependency graph snapshot. - ## Automated version updates The [auto-update](/.github/workflows/auto-update.yml) workflow runs nightly. It checks for new releases of Node.js (and their Alpine images) and of the npm CLI, and when it finds one it opens a pull request that bumps the affected versions across the [image-matrix.json](image-matrix.json), [JOB.env](JOB.env), [Dockerfile](Dockerfile), [README.md](README.md) and the [examples](examples). From 5767312aab6e0eb628a68e888a6b230e28e71514 Mon Sep 17 00:00:00 2001 From: John Watson Date: Fri, 14 Aug 2026 12:54:56 +0100 Subject: [PATCH 4/8] Merge Node examples into one multi-target Dockerfile; align naming with .NET example --- .github/workflows/auto-update.yml | 2 +- README.md | 8 +-- examples/.dockerignore | 9 --- examples/Dockerfile | 97 +++++++++++++++++++++++++++++++ examples/Dockerfile.service | 56 ------------------ examples/Dockerfile.web | 71 ---------------------- 6 files changed, 101 insertions(+), 142 deletions(-) delete mode 100644 examples/.dockerignore create mode 100644 examples/Dockerfile delete mode 100644 examples/Dockerfile.service delete mode 100644 examples/Dockerfile.web diff --git a/.github/workflows/auto-update.yml b/.github/workflows/auto-update.yml index 7ea6d8d..9cd6871 100644 --- a/.github/workflows/auto-update.yml +++ b/.github/workflows/auto-update.yml @@ -105,7 +105,7 @@ jobs: latest_alpine_version=$(jq -r '.[] | select(.tags[] == "latest") | .alpineVersion' image-matrix.json) sed -i "s/ARG BASE_VERSION=.*/ARG BASE_VERSION=${latest_node_version}-alpine${latest_alpine_version}/" Dockerfile sed -i "s/ARG DEFRA_VERSION=.*/ARG DEFRA_VERSION=${new_defra_version}/" Dockerfile - sed -i "s/ARG BASE_VERSION=.*/ARG BASE_VERSION=${new_defra_version}-node${latest_node_version}/" examples/Dockerfile.web examples/Dockerfile.service + sed -i "s/ARG PARENT_VERSION=.*/ARG PARENT_VERSION=${new_defra_version}-node${latest_node_version}/" examples/Dockerfile echo "Updated Dockerfile and examples" - name: Prepare pull request details diff --git a/README.md b/README.md index ece9ca8..affe6c8 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,11 @@ Two parent images are created for each version: It is recommended that services use [multi-stage builds](https://docs.docker.com/develop/develop-images/multistage-build) to produce production and development images, each extending the appropriate parent, from a single Dockerfile. -### Example files +### Example file -[Examples](https://github.com/DEFRA/defra-docker-node/tree/main/examples) are provided to show how parent images can be extended for different types of services. These should be a good starting point for building Node services conforming to Defra standards. +[`examples/Dockerfile`](https://github.com/DEFRA/defra-docker-node/tree/main/examples) shows how the parent images can be extended for different types of services. It should be a good starting point for building Node services conforming to Defra standards. -`Dockerfile.web` - This is an example web project, that requires a build step to create some static files that are used by the web front end. - -`Dockerfile.service` - This is an example project that doesn't expose any external ports (a message based service). There is also no build step in this Dockerfile. +Two service archetypes are shown as separate targets, sharing common `base`/`development` stages: `production-web`/`development-web`/`test-web` (a web project that exposes a port and has a build step to create static files for the front end), and `production-service`/`development-service`/`test-service` (a message-based service with no exposed ports and no build step). Build or run only the target that matches your service, e.g. `docker build --target production-web .`, and delete the stages for the archetype you don't need once you've copied this into your own repo. ## Supported Node.js versions diff --git a/examples/.dockerignore b/examples/.dockerignore deleted file mode 100644 index 5ccab37..0000000 --- a/examples/.dockerignore +++ /dev/null @@ -1,9 +0,0 @@ -node_modules -Dockerfile -.dockerignore -.git -.env -coverage -**/*.test.js -LICENCE -README.md diff --git a/examples/Dockerfile b/examples/Dockerfile new file mode 100644 index 0000000..850479a --- /dev/null +++ b/examples/Dockerfile @@ -0,0 +1,97 @@ +# Allow parent image version to be set at build time +ARG PARENT_VERSION=3.1.4-node24.19.0 + +FROM defradigital/node:$PARENT_VERSION AS base + +ENV TZ="Europe/London" + +# Copy our package files so that our package install will do a clean install. This installs the exact versions of the packages +# listed in package-lock.json, and does not update either the package-lock.json or the package.json file. +COPY --chown=node:node package*.json ./ + +# Development stage installs devDependencies and copies in source. We name this stage so we can refer to it in later stages. +FROM defradigital/node-development:$PARENT_VERSION AS development + +# We copy the installed packages from the production "base" install that was the first stage. +COPY --from=base --chown=node:node /home/node/package*.json ./ + +# We run a full dev install to bring in any development packages +RUN npm ci --ignore-scripts +COPY --chown=node:node src/ ./src/ + +# Web archetype: exposes a port and runs a build step to create static files for the front end. +FROM development AS development-web + +# Set the port that is going to be exposed later on in the Dockerfile as well. +ARG PORT=3000 +ENV PORT=${PORT} + +# Expose the PORT passed in to the Dockerfile, and also some development debugging ports that are used during development +EXPOSE ${PORT} 9229 + +# Run the build command to get the extra files needed for production. We also specify a command here, +# as we can run this stage directly using either docker-compose files, or passing --target to the docker build command +RUN npm run build +CMD [ "npm", "run", "start:watch" ] + +# Service archetype: a message-based service with no exposed ports and no build step. +FROM development AS development-service + +# Expose some development debugging ports that are used during development +EXPOSE 9229 9230 + +# We specify a command here, as we can run this stage directly using either docker-compose files, or passing --target to the docker build command +CMD [ "npm", "run", "start:watch" ] + +# Test stages copy in Jest configuration and declare the test task as the default command. We use the development-* +# stages for this as they will have all the tools required installed and have everything ready to run tests. +FROM development-web AS test-web +COPY --chown=node:node jest.config.js ./jest.config.js +COPY --chown=node:node test/ ./test/ +CMD [ "npm", "run", "test" ] + +FROM development-service AS test-service +COPY --chown=node:node jest.config.js ./jest.config.js +COPY --chown=node:node test/ ./test/ +CMD [ "npm", "run", "test" ] + +# Production stage (web) exposes the service port, copies in built app code and declares the Node app as the default command. +FROM base AS production-web + +# Switch to root temporarily to install production dependencies and lock down file permissions. +USER root + +ARG PORT=3000 +EXPOSE ${PORT} + +# Copy application artifacts and assign root ownership to prevent modification by other users. +COPY --from=development-web --chown=root:root /home/node/package*.json ./ +COPY --from=development-web --chown=root:root /home/node/src/ ./src/ + +# Install node modules, remove write permissions, then drop back to the unprivileged user. +RUN npm ci --ignore-scripts --omit=dev && chmod -R a-w /home/node +USER node + +# This is the command that is run for the production service. The parent image has an ENTRYPOINT that uses a lightweight +# init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and +# orphaned processes +CMD [ "node", "src/index" ] + +# Production stage (service) copies in built app code and declares the Node app as the default command. No port is exposed. +FROM base AS production-service + +# Switch to root temporarily to install production dependencies and lock down file permissions. +USER root + +# Copy application artifacts and assign root ownership to prevent modification by other users. +COPY --from=development-service --chown=root:root /home/node/package*.json ./ +COPY --from=development-service --chown=root:root /home/node/src/ ./src/ + +# Install node modules, remove write permissions, then drop back to the unprivileged user. +RUN npm ci --ignore-scripts --omit=dev && chmod -R a-w /home/node +USER node + +# This is the command that is run for the production service. The parent image has an ENTRYPOINT that uses a lightweight +# init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and +# orphaned processes +CMD [ "node", "src/index" ] diff --git a/examples/Dockerfile.service b/examples/Dockerfile.service deleted file mode 100644 index a7617a8..0000000 --- a/examples/Dockerfile.service +++ /dev/null @@ -1,56 +0,0 @@ -# This uses the published Defra Node.js parent images from Docker Hub: -# https://github.com/DEFRA/defra-docker-node - -ARG BASE_VERSION=3.1.4-node24.19.0 -FROM defradigital/node:$BASE_VERSION AS base - -ENV TZ="Europe/London" - -# Copy our package files so that our package install will do a clean install. This installs the exact versions of the packages -# listed in package-lock.json, and does not update either the package-lock.json or the package.json file. -COPY --chown=node:node package*.json ./ - -# Development stage installs devDependencies, builds app from source and declares a file watcher as the default command. -# We name this stage so we can refer to it in later stages -FROM defradigital/node-development:$BASE_VERSION AS development - -# Expose some development debugging ports that are used during development -EXPOSE 9229 9230 - -# We copy the installed packages from the production "base" install that was the first stage. -COPY --from=base --chown=node:node /home/node/package*.json ./ - -# We run a full dev install to bring in any development packages -RUN npm ci --ignore-scripts -COPY --chown=node:node src/ ./src/ - -# We specify a command here, as we can run this stage directly using either docker-compose files, or passing --target to the docker build command -CMD [ "npm", "run", "start:watch" ] - -# Test stage copied in Jest configuration and declares the test task as the default command. We use the development stage -# for this as it will have all the tools required installed and have everything ready to run tests -FROM development AS test - -# We copy the extra files needed for the tests into this image -COPY --chown=node:node jest.config.js ./jest.config.js -COPY --chown=node:node test/ ./test/ -CMD [ "npm", "run", "test" ] - -# Production stage exposes service port, copies in built app code and declares the Node app as the default command -FROM base AS production - -# Switch to root temporarily to install production dependencies and lock down file permissions. -USER root - -# Copy application artifacts and assign root ownership to prevent modification by other users. -COPY --from=development --chown=root:root /home/node/package*.json ./ -COPY --from=development --chown=root:root /home/node/src/ ./src/ - -# Install node modules, remove write permissions, then drop back to the unprivileged user. -RUN npm ci --ignore-scripts --omit=dev && chmod -R a-w /home/node -USER node - -# This is the command that is run for the production service. The parent image has an ENTRYPOINT that uses a lightweight -# init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and -# orphaned processes -CMD [ "node", "src/index" ] diff --git a/examples/Dockerfile.web b/examples/Dockerfile.web deleted file mode 100644 index 45f59d3..0000000 --- a/examples/Dockerfile.web +++ /dev/null @@ -1,71 +0,0 @@ -# This uses the published Defra Node.js parent images from Docker Hub: -# https://github.com/DEFRA/defra-docker-node - -ARG BASE_VERSION=3.1.4-node24.19.0 -FROM defradigital/node:$BASE_VERSION AS base - -ENV TZ="Europe/London" - -# Set the port that is going to be exposed later on in the Dockerfile as well. -ARG PORT=3000 -ENV PORT=${PORT} - -# Copy our package files so that our package install will do a clean install. This installs the exact versions of the packages -# listed in package-lock.json, and does not update either the package-lock.json or the package.json file. -COPY --chown=node:node package*.json ./ - -# Development stage installs devDependencies, builds app from source and declares a file watcher as the default command. -# We name this stage so we can refer to it in later stages -FROM defradigital/node-development:$BASE_VERSION AS development - -# Expose the PORT passed in to the Dockerfile, and also some development debugging ports that are used during development -EXPOSE ${PORT} 9229 - -# We copy the installed packages from the production "base" install that was the first stage. -COPY --from=base --chown=node:node /home/node/package*.json ./ - -# We run a full dev install to bring in any development packages -RUN npm ci --ignore-scripts -COPY --chown=node:node src/ ./src/ - -# Run the build command to get the extra files needed for production. We also specify a command here, -# as we can run this stage directly using either docker-compose files, or passing --target to the docker build command -RUN npm run build -CMD [ "npm", "run", "start:watch" ] - -# Test stage copied in Jest configuration and declares the test task as the default command. We use the development stage -# for this as it will have all the tools required installed and have everything ready to run tests -FROM development AS test - -# We copy the extra files needed for the tests into this image -COPY --chown=node:node jest.config.js ./jest.config.js -COPY --chown=node:node test/ ./test/ -CMD [ "npm", "run", "test" ] - -# Production stage exposes service port, copies in built app code and declares the Node app as the default command -FROM base AS production - -# Again, be explicit about the permissions we want for this stage -USER node -WORKDIR /home/node - -# Expose the PORT passed in at the start of the file -EXPOSE ${PORT} - -# Switch to root temporarily to install production dependencies and lock down file permissions. -USER root - -# Copy in the files that we built using the tools in the development stage, assigning root -# ownership to prevent modification by other users. This reduces the attack surface, and -# also the size of the final production image. -COPY --from=development --chown=root:root /home/node/package*.json ./ -COPY --from=development --chown=root:root /home/node/src/ ./src/ - -# Install node modules, remove write permissions, then drop back to the unprivileged user. -RUN npm ci --ignore-scripts --omit=dev && chmod -R a-w /home/node -USER node - -# This is the command that is run for the production service. The parent image has an ENTRYPOINT that uses a lightweight -# init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and -# orphaned processes -CMD [ "node", "src/index" ] From 99493180c85b9a095379f1b593a02d076e81ae3a Mon Sep 17 00:00:00 2001 From: John Watson Date: Fri, 14 Aug 2026 12:59:41 +0100 Subject: [PATCH 5/8] Use Vitest instead of Jest in example Dockerfile, per fcp-mpdp-frontend convention --- examples/Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/Dockerfile b/examples/Dockerfile index 850479a..d3f1773 100644 --- a/examples/Dockerfile +++ b/examples/Dockerfile @@ -43,15 +43,15 @@ EXPOSE 9229 9230 # We specify a command here, as we can run this stage directly using either docker-compose files, or passing --target to the docker build command CMD [ "npm", "run", "start:watch" ] -# Test stages copy in Jest configuration and declare the test task as the default command. We use the development-* +# Test stages copy in Vitest configuration and declare the test task as the default command. We use the development-* # stages for this as they will have all the tools required installed and have everything ready to run tests. FROM development-web AS test-web -COPY --chown=node:node jest.config.js ./jest.config.js +COPY --chown=node:node vitest.config.js ./vitest.config.js COPY --chown=node:node test/ ./test/ CMD [ "npm", "run", "test" ] FROM development-service AS test-service -COPY --chown=node:node jest.config.js ./jest.config.js +COPY --chown=node:node vitest.config.js ./vitest.config.js COPY --chown=node:node test/ ./test/ CMD [ "npm", "run", "test" ] From 74739024e3ca86457eea0a057d7daed6a8108c79 Mon Sep 17 00:00:00 2001 From: John Watson Date: Fri, 14 Aug 2026 13:04:37 +0100 Subject: [PATCH 6/8] Simplify example Dockerfile: drop unused base stage and test targets, closer to fcp-mpdp-frontend --- README.md | 2 +- examples/Dockerfile | 86 ++++++++++++++++++++------------------------- 2 files changed, 39 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index affe6c8..59ce1a5 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ It is recommended that services use [multi-stage builds](https://docs.docker.com [`examples/Dockerfile`](https://github.com/DEFRA/defra-docker-node/tree/main/examples) shows how the parent images can be extended for different types of services. It should be a good starting point for building Node services conforming to Defra standards. -Two service archetypes are shown as separate targets, sharing common `base`/`development` stages: `production-web`/`development-web`/`test-web` (a web project that exposes a port and has a build step to create static files for the front end), and `production-service`/`development-service`/`test-service` (a message-based service with no exposed ports and no build step). Build or run only the target that matches your service, e.g. `docker build --target production-web .`, and delete the stages for the archetype you don't need once you've copied this into your own repo. +Two service archetypes are shown as separate targets, sharing a common `development` stage: `production-web`/`development-web` (a web project that exposes a port and has a build step to create static files for the front end), and `production-service`/`development-service` (a message-based service with no exposed ports and no build step). There's no separate stage for running tests — run `npm test` directly, on the host or in CI, against the same source tree used by the `development` stage. Build or run only the target that matches your service, e.g. `docker build --target production-web .`, and delete the stages for the archetype you don't need once you've copied this into your own repo. ## Supported Node.js versions diff --git a/examples/Dockerfile b/examples/Dockerfile index d3f1773..ba21754 100644 --- a/examples/Dockerfile +++ b/examples/Dockerfile @@ -1,72 +1,59 @@ -# Allow parent image version to be set at build time +# This uses the published Defra Node.js parent images from Docker Hub: +# https://github.com/DEFRA/defra-docker-node +# +# Two service archetypes are shown as separate targets, sharing the development setup below: +# "web" (exposes a port, has a build step) and "service" (a message-based service with no +# exposed port and no build step). Build/run only the target that matches your service, e.g. +# `docker build --target production-web .`, and delete the stages for the archetype you don't +# need once you've copied this into your own repo. +# +# There's no separate stage for running tests: run `npm test` directly, on the host or in CI, +# against the same source tree used by the "development" stage. + ARG PARENT_VERSION=3.1.4-node24.19.0 +ARG PORT=3000 -FROM defradigital/node:$PARENT_VERSION AS base +# Development stage (shared): installs all dependencies and copies in source. +FROM defradigital/node-development:$PARENT_VERSION AS development ENV TZ="Europe/London" -# Copy our package files so that our package install will do a clean install. This installs the exact versions of the packages -# listed in package-lock.json, and does not update either the package-lock.json or the package.json file. -COPY --chown=node:node package*.json ./ - -# Development stage installs devDependencies and copies in source. We name this stage so we can refer to it in later stages. -FROM defradigital/node-development:$PARENT_VERSION AS development - -# We copy the installed packages from the production "base" install that was the first stage. -COPY --from=base --chown=node:node /home/node/package*.json ./ +ARG PORT +ENV PORT=${PORT} +EXPOSE ${PORT} 9229 -# We run a full dev install to bring in any development packages +COPY --chown=node:node package*.json ./ RUN npm ci --ignore-scripts COPY --chown=node:node src/ ./src/ -# Web archetype: exposes a port and runs a build step to create static files for the front end. +# Web archetype: runs a build step to create static files for the front end. FROM development AS development-web - -# Set the port that is going to be exposed later on in the Dockerfile as well. -ARG PORT=3000 -ENV PORT=${PORT} - -# Expose the PORT passed in to the Dockerfile, and also some development debugging ports that are used during development -EXPOSE ${PORT} 9229 - -# Run the build command to get the extra files needed for production. We also specify a command here, -# as we can run this stage directly using either docker-compose files, or passing --target to the docker build command RUN npm run build CMD [ "npm", "run", "start:watch" ] -# Service archetype: a message-based service with no exposed ports and no build step. +# Service archetype: a message-based service with no build step. FROM development AS development-service - -# Expose some development debugging ports that are used during development -EXPOSE 9229 9230 - -# We specify a command here, as we can run this stage directly using either docker-compose files, or passing --target to the docker build command CMD [ "npm", "run", "start:watch" ] -# Test stages copy in Vitest configuration and declare the test task as the default command. We use the development-* -# stages for this as they will have all the tools required installed and have everything ready to run tests. -FROM development-web AS test-web -COPY --chown=node:node vitest.config.js ./vitest.config.js -COPY --chown=node:node test/ ./test/ -CMD [ "npm", "run", "test" ] +# Re-runs the build with NODE_ENV=production set, for an optimised production bundle. +FROM development-web AS production-build-web +ENV NODE_ENV=production +RUN npm run build + +# Production stage (web): fresh production-only install, read-only application files. +FROM defradigital/node:$PARENT_VERSION AS production-web -FROM development-service AS test-service -COPY --chown=node:node vitest.config.js ./vitest.config.js -COPY --chown=node:node test/ ./test/ -CMD [ "npm", "run", "test" ] +ENV TZ="Europe/London" -# Production stage (web) exposes the service port, copies in built app code and declares the Node app as the default command. -FROM base AS production-web +ARG PORT +EXPOSE ${PORT} # Switch to root temporarily to install production dependencies and lock down file permissions. USER root -ARG PORT=3000 -EXPOSE ${PORT} - # Copy application artifacts and assign root ownership to prevent modification by other users. -COPY --from=development-web --chown=root:root /home/node/package*.json ./ -COPY --from=development-web --chown=root:root /home/node/src/ ./src/ +COPY --from=production-build-web --chown=root:root /home/node/package*.json ./ +COPY --from=production-build-web --chown=root:root /home/node/src/ ./src/ # Install node modules, remove write permissions, then drop back to the unprivileged user. RUN npm ci --ignore-scripts --omit=dev && chmod -R a-w /home/node @@ -77,8 +64,10 @@ USER node # orphaned processes CMD [ "node", "src/index" ] -# Production stage (service) copies in built app code and declares the Node app as the default command. No port is exposed. -FROM base AS production-service +# Production stage (service): same pattern, no build step and no exposed port. +FROM defradigital/node:$PARENT_VERSION AS production-service + +ENV TZ="Europe/London" # Switch to root temporarily to install production dependencies and lock down file permissions. USER root @@ -95,3 +84,4 @@ USER node # init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and # orphaned processes CMD [ "node", "src/index" ] + From f6e148a2a8b3d43e8b337a9c9bb5021433e60e07 Mon Sep 17 00:00:00 2001 From: John Watson Date: Fri, 14 Aug 2026 13:08:57 +0100 Subject: [PATCH 7/8] Drop web/service archetype split, use one generic example Dockerfile --- README.md | 4 +--- examples/Dockerfile | 49 +++++---------------------------------------- 2 files changed, 6 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 59ce1a5..05dfe52 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,7 @@ It is recommended that services use [multi-stage builds](https://docs.docker.com ### Example file -[`examples/Dockerfile`](https://github.com/DEFRA/defra-docker-node/tree/main/examples) shows how the parent images can be extended for different types of services. It should be a good starting point for building Node services conforming to Defra standards. - -Two service archetypes are shown as separate targets, sharing a common `development` stage: `production-web`/`development-web` (a web project that exposes a port and has a build step to create static files for the front end), and `production-service`/`development-service` (a message-based service with no exposed ports and no build step). There's no separate stage for running tests — run `npm test` directly, on the host or in CI, against the same source tree used by the `development` stage. Build or run only the target that matches your service, e.g. `docker build --target production-web .`, and delete the stages for the archetype you don't need once you've copied this into your own repo. +[`examples/Dockerfile`](https://github.com/DEFRA/defra-docker-node/tree/main/examples) shows how the parent images can be extended for a Node service, with `development` and `production` targets. There's no separate stage for running tests — run `npm test` directly, on the host or in CI, against the same source tree used by the `development` stage. If your service has no build step, delete the `RUN npm run build` lines and copy from the `development` stage directly in the production stage instead of from `production-build`. ## Supported Node.js versions diff --git a/examples/Dockerfile b/examples/Dockerfile index ba21754..00d9c05 100644 --- a/examples/Dockerfile +++ b/examples/Dockerfile @@ -1,19 +1,6 @@ -# This uses the published Defra Node.js parent images from Docker Hub: -# https://github.com/DEFRA/defra-docker-node -# -# Two service archetypes are shown as separate targets, sharing the development setup below: -# "web" (exposes a port, has a build step) and "service" (a message-based service with no -# exposed port and no build step). Build/run only the target that matches your service, e.g. -# `docker build --target production-web .`, and delete the stages for the archetype you don't -# need once you've copied this into your own repo. -# -# There's no separate stage for running tests: run `npm test` directly, on the host or in CI, -# against the same source tree used by the "development" stage. - ARG PARENT_VERSION=3.1.4-node24.19.0 ARG PORT=3000 -# Development stage (shared): installs all dependencies and copies in source. FROM defradigital/node-development:$PARENT_VERSION AS development ENV TZ="Europe/London" @@ -26,22 +13,16 @@ COPY --chown=node:node package*.json ./ RUN npm ci --ignore-scripts COPY --chown=node:node src/ ./src/ -# Web archetype: runs a build step to create static files for the front end. -FROM development AS development-web RUN npm run build CMD [ "npm", "run", "start:watch" ] -# Service archetype: a message-based service with no build step. -FROM development AS development-service -CMD [ "npm", "run", "start:watch" ] - # Re-runs the build with NODE_ENV=production set, for an optimised production bundle. -FROM development-web AS production-build-web +FROM development AS production-build ENV NODE_ENV=production RUN npm run build -# Production stage (web): fresh production-only install, read-only application files. -FROM defradigital/node:$PARENT_VERSION AS production-web +# Production stage: fresh production-only install, read-only application files. +FROM defradigital/node:$PARENT_VERSION AS production ENV TZ="Europe/London" @@ -52,8 +33,8 @@ EXPOSE ${PORT} USER root # Copy application artifacts and assign root ownership to prevent modification by other users. -COPY --from=production-build-web --chown=root:root /home/node/package*.json ./ -COPY --from=production-build-web --chown=root:root /home/node/src/ ./src/ +COPY --from=production-build --chown=root:root /home/node/package*.json ./ +COPY --from=production-build --chown=root:root /home/node/src/ ./src/ # Install node modules, remove write permissions, then drop back to the unprivileged user. RUN npm ci --ignore-scripts --omit=dev && chmod -R a-w /home/node @@ -64,24 +45,4 @@ USER node # orphaned processes CMD [ "node", "src/index" ] -# Production stage (service): same pattern, no build step and no exposed port. -FROM defradigital/node:$PARENT_VERSION AS production-service - -ENV TZ="Europe/London" - -# Switch to root temporarily to install production dependencies and lock down file permissions. -USER root - -# Copy application artifacts and assign root ownership to prevent modification by other users. -COPY --from=development-service --chown=root:root /home/node/package*.json ./ -COPY --from=development-service --chown=root:root /home/node/src/ ./src/ - -# Install node modules, remove write permissions, then drop back to the unprivileged user. -RUN npm ci --ignore-scripts --omit=dev && chmod -R a-w /home/node -USER node - -# This is the command that is run for the production service. The parent image has an ENTRYPOINT that uses a lightweight -# init program "tini" that handles signals. As long as we don't override the ENTRYPOINT the "tini" routine will handle signals and -# orphaned processes -CMD [ "node", "src/index" ] From 059534307ea27e82c2aebf61bb3fb8afa1038b64 Mon Sep 17 00:00:00 2001 From: John Watson Date: Fri, 14 Aug 2026 13:35:10 +0100 Subject: [PATCH 8/8] Add script and docs for retiring a version from the Dependency Graph --- README.md | 15 +++++++++ scripts/retire-version.sh | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100755 scripts/retire-version.sh diff --git a/README.md b/README.md index 05dfe52..938ed9e 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,21 @@ The image pushed to Docker Hub also carries the same SBOM as a build attestation docker buildx imagetools inspect defradigital/node: --format '{{json (index .SBOM "linux/amd64").SPDX}}' ``` +### Retiring a version + +Each supported Node major version submits its SBOM to the Dependency Graph under its own +correlator (`docker-image-node-`), and GitHub only ever shows the *latest* submission +for a given correlator. So if a version is simply deleted from [image-matrix.json](image-matrix.json) +once it drops out of LTS, nothing ever submits again for that correlator, and the Dependency +Graph (and any Dependabot alerts derived from it) would keep showing that version's packages +forever, frozen at whatever they were on its last build. + +To retire a version cleanly, run [`scripts/retire-version.sh`](scripts/retire-version.sh) with +the major version, e.g. `./scripts/retire-version.sh 22`. It removes the version from +`image-matrix.json` and the table above, and submits an empty snapshot for that version's +correlator to clear it from the Dependency Graph. Review the resulting diff, then commit it and +open a PR as normal. Requires `jq` and an authenticated `gh` CLI. + ## Automated version updates The [auto-update](/.github/workflows/auto-update.yml) workflow runs nightly. It checks for new releases of Node.js (and their Alpine images) and of the npm CLI, and when it finds one it opens a pull request that bumps the affected versions across the [image-matrix.json](image-matrix.json), [JOB.env](JOB.env), [Dockerfile](Dockerfile), [README.md](README.md) and the [examples](examples). diff --git a/scripts/retire-version.sh b/scripts/retire-version.sh new file mode 100755 index 0000000..7f16f94 --- /dev/null +++ b/scripts/retire-version.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Retires a Node.js major version once it drops out of LTS: removes it from +# image-matrix.json and README.md, and clears its entry from the GitHub +# Dependency Graph (which would otherwise keep showing that version's +# packages forever, since nothing else would ever resubmit for it). +# +# Usage: scripts/retire-version.sh +# Example: scripts/retire-version.sh 22 + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi +if ! command -v jq >/dev/null || ! command -v gh >/dev/null; then + echo "This script requires both jq and the GitHub CLI (gh, authenticated)." >&2 + exit 1 +fi + +MAJOR="$1" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MATRIX_FILE="$REPO_ROOT/image-matrix.json" +README_FILE="$REPO_ROOT/README.md" +REPO_SLUG="$(gh repo view --json nameWithOwner --jq .nameWithOwner)" + +FULL_VERSION=$(jq -r --arg major "$MAJOR" '.[] | select(.nodeVersion | startswith($major + ".")) | .nodeVersion' "$MATRIX_FILE") +if [[ -z "$FULL_VERSION" ]]; then + echo "No entry for Node $MAJOR found in $MATRIX_FILE" >&2 + exit 1 +fi + +echo "Removing Node $FULL_VERSION from image-matrix.json" +jq --arg major "$MAJOR" '[.[] | select((.nodeVersion | startswith($major + ".")) | not)]' "$MATRIX_FILE" > "$MATRIX_FILE.tmp" +mv "$MATRIX_FILE.tmp" "$MATRIX_FILE" + +echo "Removing the $FULL_VERSION row from README.md" +grep -v "| ${FULL_VERSION} " "$README_FILE" > "$README_FILE.tmp" +mv "$README_FILE.tmp" "$README_FILE" + +CORRELATOR="docker-image-node-$MAJOR" +echo "Clearing Dependency Graph entry for correlator $CORRELATOR" +# job.correlator + detector.name (not detector.version) are what GitHub uses to +# decide which snapshot is "current"; an empty manifest set clears that entry. +# detector.name must match what anchore/sbom-action's syft integration submits. +gh api "repos/$REPO_SLUG/dependency-graph/snapshots" --input - < Dependency graph > Dependencies that the Node $FULL_VERSION packages are gone."