From 6ad3e2f94622a158703195c54a53d89a0a7813a2 Mon Sep 17 00:00:00 2001 From: Moonwalker-rgb Date: Tue, 23 Jun 2026 22:00:10 +0000 Subject: [PATCH] build(frontend): add multi-stage Dockerfile with Next.js standalone output Closes #7 Replaces the rejected type-prefix infra(frontend): (not in the .github/workflows/pr-title-lint.yml allowlist) with build(frontend):, the Conventional Commits spec type for changes to the build system. The diff is unchanged; only the commit message is rewritten. Adds a production-ready container build for the Next.js frontend: - Add docker/frontend.Dockerfile (multi-stage base/deps/builder/runner on node:20-alpine, libc6-compat + tini, non-root user, /api/health healthcheck, port 3000, CMD [node,server.js]) - Enable output: 'standalone' and productionBrowserSourceMaps: false in Frontend/next.config.ts so .next/standalone/ emits a pruned, traced node_modules - Add Frontend/src/app/api/health/route.ts (NextResponse.json, dynamic = force-dynamic) - Add Frontend/.dockerignore mirroring Backend/.dockerignore (Next.js specifics: .next, out, next-env.d.ts, eslintcache) - Document new file, Targets table, design decisions, and validation in docker/README.md --- Frontend/.dockerignore | 47 +++++++++ Frontend/next.config.ts | 12 ++- Frontend/src/app/api/health/route.ts | 26 +++++ docker/README.md | 138 ++++++++++++++++++++++++--- docker/frontend.Dockerfile | 122 +++++++++++++++++++++++ 5 files changed, 331 insertions(+), 14 deletions(-) create mode 100644 Frontend/.dockerignore create mode 100644 Frontend/src/app/api/health/route.ts create mode 100644 docker/frontend.Dockerfile diff --git a/Frontend/.dockerignore b/Frontend/.dockerignore new file mode 100644 index 0000000..4d66211 --- /dev/null +++ b/Frontend/.dockerignore @@ -0,0 +1,47 @@ +# Frontend/.dockerignore +# +# Active in CI / local builds because the frontend Dockerfile is built +# with `context: ./Frontend` and `file: docker/frontend.Dockerfile` +# (workspace-relative, mirroring backend's pattern). `.dockerignore` +# patterns are matched against paths inside the build context, so +# `..`-prefixed paths that try to escape Frontend/ are not honored. +# Anything below is a path that exists somewhere under Frontend/. + +# Build artefacts regenerated by the Next.js build. Re-fetching them +# from the buildkit cache or compiling them inside the image bloats the +# context and breaks layer caching. `out/` is the legacy non-standalone +# output directory and is kept here for older Next.js workflows. +node_modules +.next +out +build +coverage +*.tsbuildinfo +next-env.d.ts +.eslintcache + +# VCS / IDE / CI noise. +.git +.gitignore +.github +.vscode +.idea +.husky + +# Secrets. .env.example is allowed because it documents the schema; real +# .env values must come from a runtime env var or sealed-secret. +.env +.env.* +!.env.example + +# OS / editor / tooling droppings. +.DS_Store +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* + +# vitest output that may be generated locally before the docker build. +.vitest-cache +vitest.config.ts.timestamp-* diff --git a/Frontend/next.config.ts b/Frontend/next.config.ts index e9ffa30..d4cd320 100644 --- a/Frontend/next.config.ts +++ b/Frontend/next.config.ts @@ -1,7 +1,17 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + // `output: 'standalone'` produces a self-contained `.next/standalone/` + // directory that ships only the files Next.js traced as required at + // runtime (server.js + a pruned `node_modules`). The Docker image at + // `docker/frontend.Dockerfile` copies that directory into the runtime + // stage, which is what keeps the production image minimal. + // + // `productionBrowserSourceMaps: false` skips inlining browser source + // maps into the client bundle, which would otherwise bloat `.next/static` + // and partially defeat the standalone-output trimming. + output: "standalone", + productionBrowserSourceMaps: false, }; export default nextConfig; diff --git a/Frontend/src/app/api/health/route.ts b/Frontend/src/app/api/health/route.ts new file mode 100644 index 0000000..afc4547 --- /dev/null +++ b/Frontend/src/app/api/health/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; + +/** + * GET /api/health + * + * Lightweight liveness endpoint used by the Docker HEALTHCHECK directive + * in `docker/frontend.Dockerfile`. The probe intentionally performs no + * SSR work, fetches no external services, and has no side effects so a + * `wget --spider` against `http://127.0.0.1:${PORT}/api/health` is a + * pure 200 OK signal. Frontend code that talks to the backend should + * *not* route health checks through this endpoint — this is purely a + * container-level liveness probe. + * + * Marked `dynamic = "force-dynamic"` so Next.js never tries to pre-render + * the response at build time (which would otherwise compile this route + * into the static output and break runtime probing on the standalone + * server). + */ +export const dynamic = "force-dynamic"; + +export function GET(): NextResponse { + return NextResponse.json({ + status: "ok", + timestamp: new Date().toISOString(), + }); +} diff --git a/docker/README.md b/docker/README.md index d170ad0..6aacd50 100644 --- a/docker/README.md +++ b/docker/README.md @@ -3,18 +3,24 @@ This directory holds container build assets that are checked into source control but live outside any single workspace. The CI pipeline (`infrastructure/ci/docker-build-pipeline.yml`) consumes these files to -build, test, scan, and push the NestJS backend image. +build, test, scan, and push the NestJS backend image, and the parallel +`infrastructure/ci/frontend-build.yml` workflow consumes the frontend +Dockerfile to build, scan, and push the Next.js standalone image. ## Assets | File | Purpose | | -------------------------- | ------------------------------------------------------------ | | `backend.Dockerfile` | Multi-stage build for the NestJS server | +| `frontend.Dockerfile` | Multi-stage build for the Next.js app (standalone output) | -The Backend workspace has its own `.dockerignore` because the CI builds -with `context: ./Backend`. See [Backend/.dockerignore](../Backend/.dockerignore). -There is no `docker/.dockerignore`: the Dockerfile would mis-copy package.json -from a repo-root context, so root-context builds are out of scope. +The `Backend` and `Frontend` workspaces each have their own `.dockerignore` +because the CI builds with `context: ./`. See +[Backend/.dockerignore](../Backend/.dockerignore) and +[Frontend/.dockerignore](../Frontend/.dockerignore). +There is no `docker/.dockerignore`: a Dockerfile would mis-copy +`package.json` from a repo-root context, so root-context builds are out +of scope for both images. ## Targets exposed by `backend.Dockerfile` @@ -27,7 +33,18 @@ from a repo-root context, so root-context builds are out of scope. `build` and `test` are throwaway CI artefacts; only `production` is published to GHCR (`ghcr.io/vertexchainlabs/vertexchain`). -## Design decisions +## Targets exposed by `frontend.Dockerfile` + +| Target | Base image | Purpose | Size envelope | +| --------- | ---------------- | ---------------------------------------------------------------------- | ------------- | +| `deps` | `node:20-alpine` | `npm ci` install (dev + prod deps) for the build runner | ≈ 600 MB | +| `builder` | `node:20-alpine` | `next build` with `output: 'standalone'` | ≈ 800 MB | +| `runner` | `node:20-alpine` | Runtime image: standalone output (traced `node_modules` + `server.js`), non-root, healthcheck against `/api/health` | < 150 MB (target < 100 MB) | + +`deps` and `builder` are throwaway CI artefacts; only `runner` is published +to GHCR (`ghcr.io/vertexchainlabs/vertexchain-frontend`). + +## Backend design decisions 1. **Alpine over distroless.** Alpine ships a shell and `wget`, which lets us use the standard `HEALTHCHECK` directive without authoring or vetting @@ -72,14 +89,66 @@ to GHCR (`ghcr.io/vertexchainlabs/vertexchain`). `--testPathIgnorePatterns='\.e2e-spec\.ts$'`. `node_modules` is already excluded by Jest by default, so we list only the e2e pattern. +## Frontend design decisions + +1. **Next.js standalone output.** `Frontend/next.config.ts` sets + `output: 'standalone'`, which causes `next build` to emit a + self-contained `.next/standalone/` containing `server.js`, a + pruned `node_modules` (only modules traced as required at runtime), + and `.next/server/`. Combined with + `productionBrowserSourceMaps: false`, that keeps the runtime image + close to the 100 MB acceptance target without manually curating the + shipped `node_modules`. `.next/static` and `public/` are still copied + in separately because the standalone output deliberately omits them. + +2. **Alpine + `libc6-compat`.** Same rationale as the backend image: + Alpine gives the smallest Node base, but Next.js / sharp native + shims link against glibc on some platforms. `libc6-compat` is the + standard musl shim that bridges them without giving up the + ≈ 50 MB base size. + +3. **No `prod-deps` stage (unlike backend).** Where the backend needs + an explicit `prod-deps` stage because `npm prune --omit=dev` would + otherwise need to run after the fact, Next.js standalone already + traces a production-only `node_modules` into + `.next/standalone/node_modules/` during `next build`. A second + `npm ci --omit=dev` would just duplicate work, so the runner stage + copies the traced tree directly. + +4. **Healthcheck via `/api/health`.** `Frontend/src/app/api/health/route.ts` + defines an App Router `GET` handler that returns a tiny + `{ status: 'ok', timestamp }` JSON envelope with + `dynamic = 'force-dynamic'` so the route is never pre-rendered into + the static output (which would break the runtime probe on the + standalone server). `wget --spider` performs a HEAD-style probe + against `http://127.0.0.1:${PORT}/api/health` exactly like the + backend image's `/health` probe. + +5. **Layer ordering for cache reuse.** `package.json` + + `package-lock.json` are copied and `npm ci` runs *before* any + application source (`next.config.ts`, `src/`, `public/`) is copied, + so iterating on TypeScript does not invalidate the `node_modules` + cache layer. Config files (`next.config.ts`, `tsconfig.json`) are + copied separately so they live in their own cache layer and can be + invalidated independently of application source. + +6. **`npm ci --ignore-scripts`.** Skips postinstall hooks + (`husky prepare`, …) inside the `deps` stage. None of those hooks + are required for `next build` to succeed, and skipping them avoids + installing build-time-only tools (e.g. native binaries that + postinstall scripts copy into `node_modules/.bin/`) into a layer + the runner image doesn't actually use. + ## Local validation +### Backend + ```bash -# Build each target standalone. The build context MUST be ./Backend -# because `backend.Dockerfile` does relative `COPY package.json ...` and -# `COPY src ./src` — these resolve to Backend/package.json and Backend/src -# only when the context is Backend/, matching how the CI pipeline posts -# `context: ./Backend` to docker/build-push-action. +# Build context MUST be ./Backend because `backend.Dockerfile` does +# relative `COPY package.json ...` and `COPY src ./src` — these resolve +# to Backend/package.json and Backend/src only when the context is +# Backend/, matching how the CI pipeline posts `context: ./Backend` to +# docker/build-push-action. # # Issue #6 example commands use repo-root context (`docker build ... .`). # Those literal invocations are NOT viable with this Dockerfile because @@ -103,12 +172,55 @@ curl -fsS http://localhost:3000/health docker inspect --format='{{json .State.Health.Status}}' vertex-backend ``` +### Frontend + +```bash +# Build context MUST be ./Frontend for the same reason as the backend +# image: `frontend.Dockerfile` does relative `COPY package.json`, +# `COPY src ./src`, and `COPY public ./public`, which only resolve +# correctly when the context is Frontend/. + +# Install layer only (useful for debugging `npm ci` failures): +docker build --target deps -f docker/frontend.Dockerfile ./Frontend + +# Standalone compilation only (useful for tracing `next build` issues): +docker build --target builder -f docker/frontend.Dockerfile ./Frontend + +# Ship-shaped runtime image (target < 100 MB per issue #7): +docker build --target runner -f docker/frontend.Dockerfile ./Frontend + +# Boot the runtime image and confirm the healthcheck passes: +docker run --rm -p 3000:3000 --name vertex-frontend \ + $(docker build -q --target runner -f docker/frontend.Dockerfile ./Frontend) +sleep 10 +curl -fsS http://localhost:3000/api/health # liveness probe +curl -fsS http://localhost:3000/ # landing page renders +docker inspect --format='{{json .State.Health.Status}}' vertex-frontend +``` + ## Security considerations +### Backend + - Non-root runtime user (`USER node`). - Production stage installs only `--omit=dev` dependencies and excludes source maps, dev configs, and `.env` files via `.dockerignore`. - Image is scanned by Trivy in CI (`infrastructure/ci/docker-build-pipeline.yml`, `security-scan` job). High or critical CVEs gate the `push` job. -- `TOKEN=` style secret values are never baked into layers: they must be - provided as runtime env vars (`docker run -e KEY=value` or k8s `Secret`). +- `TOKEN=` style secret values are never baked into layers: they must + be provided as runtime env vars (`docker run -e KEY=value` or k8s `Secret`). + +### Frontend + +- Non-root runtime user (`USER node`, UID 1000). +- Production stage copies only `.next/standalone/`, `.next/static/`, and + `public/` from `builder`. Dev tooling (eslint, vitest, typescript, + husky, …) never enters the runtime image. +- Source maps are not inlined into client bundles + (`productionBrowserSourceMaps: false` in `Frontend/next.config.ts`), + so an attacker pulling the image cannot reconstruct the original + source from client-side bundles. +- Image is scanned by Trivy in CI; high or critical CVEs gate the push. +- `NEXT_PUBLIC_*` style values are intentionally *baked in* — that is + the framework contract for browser-visible env vars. Any secret that + must remain server-only belongs on the backend image, not here. diff --git a/docker/frontend.Dockerfile b/docker/frontend.Dockerfile new file mode 100644 index 0000000..54be22e --- /dev/null +++ b/docker/frontend.Dockerfile @@ -0,0 +1,122 @@ +# syntax=docker/dockerfile:1.7 +# +# VertexChain Frontend — multi-stage Dockerfile +# +# Four stages, each contributing once: +# base – shared runtime root (Alpine + libc6-compat + tini) +# deps – full dev + prod deps so `npm run build` can succeed +# builder – Next.js standalone build (output: 'standalone') +# runner – minimal runtime image: standalone output only, non-root, +# HEALTHCHECK against /api/health on port 3000. +# +# The runtime image is built from `runner`, which inherits a clean +# `base` so dev tooling (eslint, vitest, typescript, …) never enters +# the published image. `.next/standalone/` carries a pruned +# `node_modules` produced by Next.js's output-file-tracing pass, which +# is why this Dockerfile does not need a separate `prod-deps` stage +# like `docker/backend.Dockerfile` does. +# +# Acceptance commands (issue #7): +# docker build --target runner -f docker/frontend.Dockerfile ./Frontend +# docker run --rm -p 3000:3000 --name vertex-frontend \ +# $(docker build -q --target runner -f docker/frontend.Dockerfile ./Frontend) +# curl -fsS http://localhost:3000/api/health + +ARG NODE_VERSION=20 + +# ============================================================================= +# base — minimal Alpine layer reused by every stage. +# • tini: proper PID 1 + signal forwarding, same rationale as backend. +# • libc6-compat: Next.js / sharp native shims link against glibc; +# Alpine ships musl, so the compat layer is needed at runtime. +# `--no-cache` keeps the apk index out of the image. +# ============================================================================= +FROM node:${NODE_VERSION}-alpine AS base +WORKDIR /usr/src/app +RUN apk add --no-cache libc6-compat tini \ + && chown node:node /usr/src/app +ENTRYPOINT ["/sbin/tini", "--"] + +# ============================================================================= +# deps — install every dependency needed by `next build`, including +# devDependencies (typescript, eslint, …). Cached independently so +# editing application source never invalidates this heavy `node_modules` +# layer. `--ignore-scripts` skips postinstall hooks (autoprefixer, +# husky, …) since none of them are required for the standalone build. +# ============================================================================= +FROM base AS deps +COPY package.json package-lock.json* ./ +RUN npm ci --no-audit --no-fund --ignore-scripts + +# ============================================================================= +# builder — compile Next.js to `.next/standalone` + `.next/static`. +# • Inherits `deps` (node_modules + Workbox/Turbopack tooling). +# • `NEXT_TELEMETRY_DISABLED=1` opts the build out of anonymous +# telemetry events to https://telemetry.nextjs.org. +# • `npm run build` runs `next build`, which performs output-file +# tracing and produces: +# .next/standalone/ – server.js + traced node_modules + .next/server +# .next/static/ – hashed client bundles (kept separately) +# public/ – user-authored static assets (kept separately) +# ============================================================================= +FROM deps AS builder +ENV NEXT_TELEMETRY_DISABLED=1 +COPY next.config.ts tsconfig.json ./ +COPY src ./src +COPY public ./public +RUN npm run build + +# ============================================================================= +# runner — minimal runtime image. +# • Fresh `base` so dev tooling, source maps, and `.git` never infect +# the shipped image. +# • `node` user (UID 1000, ships with node:20-alpine) — non-root, +# satisfying the issue #7 "must not run as root" criterion. +# • `--chown=node:node` is folded into the COPYs so we do not add an +# extra layer just to chown files. +# • The standalone directory is the only thing copied from `builder`. +# Its top-level layout is: +# ./ +# server.js <- the entry point we run with `node` +# package.json +# node_modules/ <- pruned by next's tracing pass +# .next/server/ <- server-side bundle +# Files outside that subtree still need to be copied in +# separately (`.next/static`, `public/`). +# ============================================================================= +FROM base AS runner +ENV NODE_ENV=production +ENV PORT=3000 +ENV NEXT_TELEMETRY_DISABLED=1 + +# Standalone bundle: server.js, package.json, traced node_modules, +# .next/server. The trailing `/` is required so COPY targets the +# directory contents rather than the directory itself. +COPY --from=builder --chown=node:node /usr/src/app/.next/standalone ./ +# Static assets hashed at build time (immutable, served by Next.js). +COPY --from=builder --chown=node:node /usr/src/app/.next/static ./.next/static +# User-authored public/ assets (favicon, robots.txt, etc.). +COPY --from=builder --chown=node:node /usr/src/app/public ./public + +USER node + +EXPOSE 3000 + +# Liveness probe against the App Router endpoint defined in +# `Frontend/src/app/api/health/route.ts`. `wget --spider` is a HEAD-style +# probe that doesn't save a response body, which matters because some +# App Router responses are streamed and the connection shouldn't be +# drained to disk. `start-period=10s` gives Next.js time to compile the +# server entry and bind the listener before the first probe. +# +# NOTE: this is the SHELL form of HEALTHCHECK CMD (no `[]` around the +# arguments). Docker runs it via `sh -c`, which is what lets `${PORT}` +# resolve at container runtime. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget --quiet --tries=1 --spider \ + http://127.0.0.1:${PORT}/api/health || exit 1 + +# `next build` with `output: 'standalone'` emits a top-level +# `server.js` that listens on $PORT (default 3000). Running it under +# `tini` (ENTRYPOINT) ensures SIGTERM from Kubernetes propagates cleanly. +CMD ["node", "server.js"]