From 58bee92b8c7c70f9d1f0a9699b0d002d37931717 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Wed, 20 May 2026 16:03:48 -0400 Subject: [PATCH 01/13] feat(nextjs): standalone output, deterministic builds, and scoped Fastly purge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coordinated improvements to the Next.js deployment pipeline: 1. Scoped Fastly cache invalidation - Add Surrogate-Key: html-pages response header to HTML routes and sitemaps (excludes /_next/static/ via the existing file-extension regex), so purge/html-pages at deploy time no longer invalidates immutable content-addressed static chunks. 2. Deterministic builds - Add generateBuildId returning NEXT_PUBLIC_VERSION || GIT_REF || 'dev' so the build manifest filename is stable across identical builds. - Override webpack output.filename / output.chunkFilename to use [contenthash] instead of [chunkhash], making chunk filenames depend only on content rather than module graph ordering. 3. Standalone output + slimmed Docker image - Add output: 'standalone' to next.config.js so Next.js emits a self-contained server with a minimal node_modules tree. - Remove the build_skip_yarn Docker stage; add a new slim runner stage that copies .next/standalone/, .next/static/, and public/ from the build stage into a clean node:24-alpine image. The build is now fully baked in at image build time — no EFS volume or Kubernetes Job needed at deploy time. - Add ARG/ENV GIT_REF to the build stage so the git SHA passed by Concourse as BUILD_ARG_GIT_REF is available to next build. BREAKING: this Dockerfile change must be deployed together with the corresponding ol-infrastructure Pulumi change that removes the blue/green EFS deployment and Kubernetes build Job. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontends/main/Dockerfile.web | 47 ++++++++++++++++++------------ frontends/main/next.config.js | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/frontends/main/Dockerfile.web b/frontends/main/Dockerfile.web index db9efa8100..a113972e9d 100644 --- a/frontends/main/Dockerfile.web +++ b/frontends/main/Dockerfile.web @@ -105,6 +105,12 @@ ENV NEXT_PUBLIC_SENTRY_ENV=$NEXT_PUBLIC_SENTRY_ENV ARG NEXT_PUBLIC_VERSION ENV NEXT_PUBLIC_VERSION=$NEXT_PUBLIC_VERSION +# GIT_REF is the full commit SHA, passed by Concourse as BUILD_ARG_GIT_REF. +# It is used by next.config.js generateBuildId as a fallback when +# NEXT_PUBLIC_VERSION is not set (e.g. CI/main-branch builds). +ARG GIT_REF +ENV GIT_REF=$GIT_REF + ARG NEXT_PUBLIC_SENTRY_PROFILES_SAMPLE_RATE ENV NEXT_PUBLIC_SENTRY_PROFILES_SAMPLE_RATE=$NEXT_PUBLIC_SENTRY_PROFILES_SAMPLE_RATE @@ -156,32 +162,37 @@ ENV NEXT_PUBLIC_DEFAULT_SEARCH_STALENESS_PENALTY="2.5" ENV NEXT_PUBLIC_DEFAULT_SEARCH_MINIMUM_SCORE_CUTOFF="0" ENV NEXT_PUBLIC_DEFAULT_SEARCH_MAX_INCOMPLETENESS_PENALTY="90" -# NEW STAGE: build_skip_yarn -# This stage is intended for scenarios where the 'yarn build' step -# is not needed (e.g., build artifacts are provided externally or -# for a development setup where 'yarn dev' might be used). -# Note: 'yarn start' in the CMD requires a prior build to have occurred -# and the '.next' directory to be present. -FROM base AS build_skip_yarn +# STAGE: build +# Runs `next build` to compile the application. With output: "standalone" in +# next.config.js this emits a minimal server at .next/standalone/ that includes +# only the required runtime files and can be run with plain node. +FROM base AS build -EXPOSE 3000 +RUN yarn build -ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" +# STAGE: runner (default) +# Copies only the standalone server, static assets, and public directory from +# the build stage into a clean image. No yarn, no node_modules, no source code. +# +# The standalone server is started directly with node (not `yarn start`), which +# avoids yarn overhead and is the pattern recommended by Next.js for Docker: +# https://github.com/vercel/next.js/tree/canary/examples/with-docker +FROM node:24-alpine AS runner -CMD ["yarn", "start"] +WORKDIR /app -# DEFAULT STAGE: build -# This stage performs the 'yarn build' step and is the default target -# if no --target is specified during the Docker build command. -FROM base AS build +# Copy the standalone server (includes a minimal node_modules) +COPY --from=build /app/frontends/main/.next/standalone ./ -RUN yarn build +# Copy static assets served by the Next.js server under /_next/static/ +COPY --from=build /app/frontends/main/.next/static ./frontends/main/.next/static + +# Copy the public directory (robots.txt, favicon.ico, etc.) +COPY --from=build /app/frontends/main/public ./frontends/main/public EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -# CMD ["node", "/app/frontends/main/.next/standalone/frontends/main/server.js"] -CMD ["yarn", "start"] +CMD ["node", "frontends/main/server.js"] diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index 5cb947d145..f33158a3c8 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -38,6 +38,13 @@ const processFeatureFlags = () => { const nextConfig = { productionBrowserSourceMaps: true, allowedDevOrigins, + /** + * Standalone output emits a minimal self-contained server at + * .next/standalone/ with only the required runtime files. The resulting + * Docker image requires no node_modules and no yarn at startup. + * See: https://nextjs.org/docs/app/getting-started/deploying#docker + */ + output: "standalone", async rewrites() { return [ /* Static assets moved from /static, though image paths are sometimes @@ -76,6 +83,9 @@ const nextConfig = { key: "Cache-Control", value: PAGE_CACHE_CONTROL, }, + // Tag sitemaps with "html-pages" so Fastly can purge them on deploy + // without also purging immutable /_next/static/ chunks. + { key: "Surrogate-Key", value: "html-pages" }, ], }, /* This is intended to target the base HTML responses and streamed RSC @@ -92,6 +102,11 @@ const nextConfig = { key: "Cache-Control", value: PAGE_CACHE_CONTROL, }, + // Tag all HTML/page routes so Fastly can purge them on deploy + // without also purging immutable /_next/static/ chunks. + // The pattern above already excludes file extensions, so /_next/static/*.js + // will never receive this tag. + { key: "Surrogate-Key", value: "html-pages" }, ], }, @@ -141,6 +156,46 @@ const nextConfig = { // Explicitly enable it for clarity (optional - already default) turbopackFileSystemCacheForDev: true, }, + + /** + * Stable, deterministic build ID based on the git SHA / version tag. + * + * Next.js embeds the build ID in manifest filenames (_buildManifest.js, + * _ssgManifest.js) and in page-data paths. Using the same ID across + * rebuilds of the same commit reduces inter-build hash drift. + * + * NEXT_PUBLIC_VERSION is set as a Kubernetes env var (and as a Docker + * build arg in future standalone builds). GIT_REF is the full commit SHA + * passed by Concourse. The 'dev' fallback is for local builds. + */ + generateBuildId: async () => + process.env.NEXT_PUBLIC_VERSION || process.env.GIT_REF || "dev", + + /** + * Replace webpack's [chunkhash] with [contenthash]. + * + * [chunkhash] is influenced by module ordering and internal IDs, so two + * builds of identical code can produce different filenames. [contenthash] + * is derived purely from the file's content, making chunk names stable + * across rebuilds when code is unchanged. + * + * See: https://github.com/vercel/next.js/discussions/65856 + */ + webpack: (config) => { + if (config.output.filename) { + config.output.filename = config.output.filename.replace( + "[chunkhash]", + "[contenthash]", + ) + } + if (config.output.chunkFilename) { + config.output.chunkFilename = config.output.chunkFilename.replace( + "[chunkhash]", + "[contenthash]", + ) + } + return config + }, } const { withSentryConfig } = require("@sentry/nextjs") From d77770c7831d86ad5fcf0f7b52f89152690c35e1 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Wed, 20 May 2026 16:17:44 -0400 Subject: [PATCH 02/13] fix: address PR review feedback on runner stage and surrogate key pattern - Fix misleading comment: standalone bundle includes a minimal node_modules, not zero node_modules - Add NODE_ENV=production to runner stage; it was previously inherited from the base stage but the runner uses a fresh FROM so must be set explicitly - Exclude /healthcheck from the html-pages Surrogate-Key pattern; healthcheck returns JSON and should not be tagged as an HTML page for Fastly purges Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- frontends/main/Dockerfile.web | 5 ++++- frontends/main/next.config.js | 8 ++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/frontends/main/Dockerfile.web b/frontends/main/Dockerfile.web index a113972e9d..a670a64314 100644 --- a/frontends/main/Dockerfile.web +++ b/frontends/main/Dockerfile.web @@ -172,7 +172,9 @@ RUN yarn build # STAGE: runner (default) # Copies only the standalone server, static assets, and public directory from -# the build stage into a clean image. No yarn, no node_modules, no source code. +# the build stage into a clean image. No workspace install is needed; the +# standalone bundle ships with the minimal node_modules required to run the +# server. No source code or build tooling is included. # # The standalone server is started directly with node (not `yarn start`), which # avoids yarn overhead and is the pattern recommended by Next.js for Docker: @@ -192,6 +194,7 @@ COPY --from=build /app/frontends/main/public ./frontends/main/public EXPOSE 3000 +ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME="0.0.0.0" diff --git a/frontends/main/next.config.js b/frontends/main/next.config.js index f33158a3c8..9569a9b895 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -93,10 +93,12 @@ const nextConfig = { * sets no-cache. However we are currently serving public content that is * cacheable. * - * Excludes everything with a file extension so we're matching only on routes. + * Excludes everything with a file extension (so /_next/static/*.js is + * never matched) and also excludes /healthcheck, which returns JSON and + * should not be tagged as an HTML page for Fastly surrogate-key purges. */ { - source: "/((?!.*\\.[a-zA-Z0-9]{2,4}$).*)", + source: "/((?!.*\\.[a-zA-Z0-9]{2,4}$)(?!healthcheck$).*)", headers: [ { key: "Cache-Control", @@ -104,8 +106,6 @@ const nextConfig = { }, // Tag all HTML/page routes so Fastly can purge them on deploy // without also purging immutable /_next/static/ chunks. - // The pattern above already excludes file extensions, so /_next/static/*.js - // will never receive this tag. { key: "Surrogate-Key", value: "html-pages" }, ], }, From 43048fb64adf00f0aa97aa9fc218f44076771695 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Wed, 20 May 2026 17:10:37 -0400 Subject: [PATCH 03/13] feat(nextjs): runtime env injection via PublicEnvScript + env() helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace webpack build-time NEXT_PUBLIC_* inlining with a runtime injection pattern that allows a single Docker image to be deployed across environments. Problem: DefinePlugin bakes all process.env.NEXT_PUBLIC_* references as literal values at build time. When yarn build runs in CI (without per-env values), all NEXT_PUBLIC_* vars are empty strings in the bundle—even though the Kubernetes pod has the correct values set. Solution: - PublicEnvScript: a Server Component that calls connection() (opts out of SSG) and renders a synchronous inline injection. The app + * currently has no CSP; if one is added, this script will need a nonce. + */ +import React from "react" +import { connection } from "next/server" + +export async function PublicEnvScript() { + // `connection()` opts this route out of static prerendering so that + // process.env is read fresh on every request (not baked at build time). + await connection() + + const publicEnv = Object.fromEntries( + Object.entries(process.env).filter(([k]) => k.startsWith("NEXT_PUBLIC_")), + ) + + // Escape `<` to prevent a value like `