# syntax=docker/dockerfile:1 # # The frontend deployment artifact. The repository had none — `dist/server.mjs` # is a preview server that applies neither the security headers nor the cache # policy `config/hosting/` declares — so a deployment had nothing to run. # # Two stages: the build produces `dist/` and, from the serving contract, the # nginx configuration that matches it; the runtime is nginx with both. # --------------------------------------------------------------------------- # build # --------------------------------------------------------------------------- # Pinned by digest: the release-provenance gate requires an immutable runner # identity, and a floating tag cannot give one. FROM node@sha256:3638d9a6fe4030bd716be989438248074489337ba3275657f93595428be4fc03 AS build WORKDIR /src # The profile is baked at build time (scripts/generate-runtime-config.ts), so it # has to be chosen here rather than at `docker run`. `dist/config.json` stays a # separate file in the image, which is what makes build-once/promote possible: # a deployment can replace just that file without rebuilding the bundle. ARG APP_PROFILE=production ENV APP_PROFILE=${APP_PROFILE} # The bundle and the nginx locations must agree on the prefix the deployment # serves this under: "/" at a domain root, "/dev/" behind a path prefix. ARG VITE_ROUTER_BASE_PATH=/ ENV VITE_ROUTER_BASE_PATH=${VITE_ROUTER_BASE_PATH} # `CI=true` turns on the release-provenance gate (scripts/lib/build-environment.ts), # which refuses to build without an identity for the artifact. That is the point: # a deployed bundle that cannot say which commit it came from is not traceable, # and the checklist asks exactly that. Supplied as build args so the caller — # a pipeline or the deploy script — owns the values. ENV CI=true ARG VITE_BUILD_ID ARG VITE_COMMIT_SHA ARG RELEASE_ID ARG CI_RUNNER_IMAGE ARG SOURCE_DATE_EPOCH ENV VITE_BUILD_ID=${VITE_BUILD_ID} ENV VITE_COMMIT_SHA=${VITE_COMMIT_SHA} ENV RELEASE_ID=${RELEASE_ID} ENV CI_RUNNER_IMAGE=${CI_RUNNER_IMAGE} ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} # The two values a deployment is allowed to supply (scripts/generate-runtime- # config.ts OVERRIDES); everything else is fixed by the profile. API_BASE_URL # has to be absolute — the runtime canonicalises it with `new URL(value)` — so # even a same-origin deployment names its own origin here. The committed # production profile ships a placeholder (https://api.example.com/), which is # what a deployment that forgets this would silently serve. ARG RUNTIME_API_BASE_URL ARG RUNTIME_TELEMETRY_ENDPOINT ENV RUNTIME_API_BASE_URL=${RUNTIME_API_BASE_URL} ENV RUNTIME_TELEMETRY_ENDPOINT=${RUNTIME_TELEMETRY_ENDPOINT} RUN corepack enable # Dependencies first so a source-only change does not re-resolve them. COPY package.json pnpm-lock.yaml ./ RUN corepack pnpm install --frozen-lockfile --ignore-scripts COPY . . RUN corepack pnpm build \ && node scripts/generate-nginx-config.ts # --------------------------------------------------------------------------- # runtime # --------------------------------------------------------------------------- FROM nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10 AS runtime # Replaces the packaged default server block; the generated file is the whole # server definition, including the BFF proxy locations. RUN rm /etc/nginx/conf.d/default.conf COPY --from=build /src/dist/nginx.conf /etc/nginx/conf.d/tech-log.conf COPY --from=build /src/dist/ /usr/share/nginx/html/ # The generated config is served from /usr/share/nginx/html as root, so the two # copies above would also publish nginx.conf itself. It is not secret, but it is # not a page either. RUN rm -f /usr/share/nginx/html/nginx.conf /usr/share/nginx/html/server.mjs \ && rm -rf /usr/share/nginx/html/.vite \ # The build writes config.json 0600, which nginx (running as `nginx`) cannot # read — the container came up healthy and answered 403 for the one file the # SPA needs before it can boot. Normalise what is served to world-readable. && chmod -R a+rX /usr/share/nginx/html EXPOSE 80 # No `nginx -t` here: proxy_pass names are resolved when the config loads, and # `backend`/`keycloak` only exist on the compose network. The container's own # startup is the check, and it fails loudly. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://127.0.0.1${VITE_ROUTER_BASE_PATH:-/}config.json || exit 1