The repository had no container image and no production-shaped serving configuration. `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 correct to run. `scripts/generate-nginx-config.ts` derives the server block from `dist/tech-log-serving-contract.json` plus the two hosting policy files, so the served headers and cache lifetimes cannot drift from what the contract declares. It emits no TLS and no proxy blocks: the edge terminates TLS and routes /api, and baking a backend address into the image would tie the bundle to one deployment. Static surfaces use `alias` because a base-path build serves /dev/assets/... out of dist/assets/..., which `root` plus URI would look for one directory too deep. The image copies that config next to the bundle and normalises permissions: the build writes config.json 0600, which nginx cannot read, so the container came up healthy and answered 403 for the one file the SPA needs to boot. index.html never referenced public/favicon.svg. The file shipped and nginx served it, but browsers asked for /favicon.ico, got a 404, and fell back to the default icon. `%BASE_URL%` rather than an absolute path so a prefixed deployment points at its own copy. development.json moves to the HTTP Studio source; the mock source has no backend to authenticate against, which is the whole point of that profile.
97 lines
4.4 KiB
Docker
97 lines
4.4 KiB
Docker
# 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
|