From 9c03715ce0a9c83c7a52356fb15f6e59809f5dbb Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Wed, 20 May 2026 16:03:48 -0400 Subject: [PATCH 01/10] 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 2fa3a6961d..6e51225ca4 100644 --- a/frontends/main/Dockerfile.web +++ b/frontends/main/Dockerfile.web @@ -110,6 +110,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 @@ -161,32 +167,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 303a8b29f4..f9418dfbf2 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -31,6 +31,13 @@ const processFeatureFlags = () => { /** @type {import('next').NextConfig} */ const nextConfig = { productionBrowserSourceMaps: true, + /** + * 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 @@ -81,6 +88,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 @@ -97,6 +107,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" }, ], }, @@ -146,6 +161,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 d44adde9f32a91cde55de3a1beae902f9a58a30d Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Wed, 20 May 2026 16:17:44 -0400 Subject: [PATCH 02/10] 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 6e51225ca4..4de4b1104d 100644 --- a/frontends/main/Dockerfile.web +++ b/frontends/main/Dockerfile.web @@ -177,7 +177,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: @@ -197,6 +199,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 f9418dfbf2..f1ffd01cb4 100644 --- a/frontends/main/next.config.js +++ b/frontends/main/next.config.js @@ -98,10 +98,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", @@ -109,8 +111,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 ffe516102ffbc1a21f5af9a1c729637a4eb5e302 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Wed, 20 May 2026 17:10:37 -0400 Subject: [PATCH 03/10] 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 `