Skip to content

Commit 55ca605

Browse files
Merge pull request #87 from Moonwalker-rgb/infra/frontend-dockerfile-7
build(frontend): add multi-stage Dockerfile with Next.js standalone output
2 parents 997c18c + 6ad3e2f commit 55ca605

5 files changed

Lines changed: 331 additions & 14 deletions

File tree

Frontend/.dockerignore

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Frontend/.dockerignore
2+
#
3+
# Active in CI / local builds because the frontend Dockerfile is built
4+
# with `context: ./Frontend` and `file: docker/frontend.Dockerfile`
5+
# (workspace-relative, mirroring backend's pattern). `.dockerignore`
6+
# patterns are matched against paths inside the build context, so
7+
# `..`-prefixed paths that try to escape Frontend/ are not honored.
8+
# Anything below is a path that exists somewhere under Frontend/.
9+
10+
# Build artefacts regenerated by the Next.js build. Re-fetching them
11+
# from the buildkit cache or compiling them inside the image bloats the
12+
# context and breaks layer caching. `out/` is the legacy non-standalone
13+
# output directory and is kept here for older Next.js workflows.
14+
node_modules
15+
.next
16+
out
17+
build
18+
coverage
19+
*.tsbuildinfo
20+
next-env.d.ts
21+
.eslintcache
22+
23+
# VCS / IDE / CI noise.
24+
.git
25+
.gitignore
26+
.github
27+
.vscode
28+
.idea
29+
.husky
30+
31+
# Secrets. .env.example is allowed because it documents the schema; real
32+
# .env values must come from a runtime env var or sealed-secret.
33+
.env
34+
.env.*
35+
!.env.example
36+
37+
# OS / editor / tooling droppings.
38+
.DS_Store
39+
*.log
40+
npm-debug.log*
41+
pnpm-debug.log*
42+
yarn-debug.log*
43+
yarn-error.log*
44+
45+
# vitest output that may be generated locally before the docker build.
46+
.vitest-cache
47+
vitest.config.ts.timestamp-*

Frontend/next.config.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import type { NextConfig } from "next";
22

33
const nextConfig: NextConfig = {
4-
/* config options here */
4+
// `output: 'standalone'` produces a self-contained `.next/standalone/`
5+
// directory that ships only the files Next.js traced as required at
6+
// runtime (server.js + a pruned `node_modules`). The Docker image at
7+
// `docker/frontend.Dockerfile` copies that directory into the runtime
8+
// stage, which is what keeps the production image minimal.
9+
//
10+
// `productionBrowserSourceMaps: false` skips inlining browser source
11+
// maps into the client bundle, which would otherwise bloat `.next/static`
12+
// and partially defeat the standalone-output trimming.
13+
output: "standalone",
14+
productionBrowserSourceMaps: false,
515
};
616

717
export default nextConfig;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { NextResponse } from "next/server";
2+
3+
/**
4+
* GET /api/health
5+
*
6+
* Lightweight liveness endpoint used by the Docker HEALTHCHECK directive
7+
* in `docker/frontend.Dockerfile`. The probe intentionally performs no
8+
* SSR work, fetches no external services, and has no side effects so a
9+
* `wget --spider` against `http://127.0.0.1:${PORT}/api/health` is a
10+
* pure 200 OK signal. Frontend code that talks to the backend should
11+
* *not* route health checks through this endpoint — this is purely a
12+
* container-level liveness probe.
13+
*
14+
* Marked `dynamic = "force-dynamic"` so Next.js never tries to pre-render
15+
* the response at build time (which would otherwise compile this route
16+
* into the static output and break runtime probing on the standalone
17+
* server).
18+
*/
19+
export const dynamic = "force-dynamic";
20+
21+
export function GET(): NextResponse {
22+
return NextResponse.json({
23+
status: "ok",
24+
timestamp: new Date().toISOString(),
25+
});
26+
}

docker/README.md

Lines changed: 125 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,24 @@
33
This directory holds container build assets that are checked into source
44
control but live outside any single workspace. The CI pipeline
55
(`infrastructure/ci/docker-build-pipeline.yml`) consumes these files to
6-
build, test, scan, and push the NestJS backend image.
6+
build, test, scan, and push the NestJS backend image, and the parallel
7+
`infrastructure/ci/frontend-build.yml` workflow consumes the frontend
8+
Dockerfile to build, scan, and push the Next.js standalone image.
79

810
## Assets
911

1012
| File | Purpose |
1113
| -------------------------- | ------------------------------------------------------------ |
1214
| `backend.Dockerfile` | Multi-stage build for the NestJS server |
15+
| `frontend.Dockerfile` | Multi-stage build for the Next.js app (standalone output) |
1316

14-
The Backend workspace has its own `.dockerignore` because the CI builds
15-
with `context: ./Backend`. See [Backend/.dockerignore](../Backend/.dockerignore).
16-
There is no `docker/.dockerignore`: the Dockerfile would mis-copy package.json
17-
from a repo-root context, so root-context builds are out of scope.
17+
The `Backend` and `Frontend` workspaces each have their own `.dockerignore`
18+
because the CI builds with `context: ./<workspace>`. See
19+
[Backend/.dockerignore](../Backend/.dockerignore) and
20+
[Frontend/.dockerignore](../Frontend/.dockerignore).
21+
There is no `docker/.dockerignore`: a Dockerfile would mis-copy
22+
`package.json` from a repo-root context, so root-context builds are out
23+
of scope for both images.
1824

1925
## Targets exposed by `backend.Dockerfile`
2026

@@ -27,7 +33,18 @@ from a repo-root context, so root-context builds are out of scope.
2733
`build` and `test` are throwaway CI artefacts; only `production` is published
2834
to GHCR (`ghcr.io/vertexchainlabs/vertexchain`).
2935

30-
## Design decisions
36+
## Targets exposed by `frontend.Dockerfile`
37+
38+
| Target | Base image | Purpose | Size envelope |
39+
| --------- | ---------------- | ---------------------------------------------------------------------- | ------------- |
40+
| `deps` | `node:20-alpine` | `npm ci` install (dev + prod deps) for the build runner | ≈ 600 MB |
41+
| `builder` | `node:20-alpine` | `next build` with `output: 'standalone'` | ≈ 800 MB |
42+
| `runner` | `node:20-alpine` | Runtime image: standalone output (traced `node_modules` + `server.js`), non-root, healthcheck against `/api/health` | < 150 MB (target < 100 MB) |
43+
44+
`deps` and `builder` are throwaway CI artefacts; only `runner` is published
45+
to GHCR (`ghcr.io/vertexchainlabs/vertexchain-frontend`).
46+
47+
## Backend design decisions
3148

3249
1. **Alpine over distroless.** Alpine ships a shell and `wget`, which lets
3350
us use the standard `HEALTHCHECK` directive without authoring or vetting
@@ -72,14 +89,66 @@ to GHCR (`ghcr.io/vertexchainlabs/vertexchain`).
7289
`--testPathIgnorePatterns='\.e2e-spec\.ts$'`. `node_modules` is already
7390
excluded by Jest by default, so we list only the e2e pattern.
7491

92+
## Frontend design decisions
93+
94+
1. **Next.js standalone output.** `Frontend/next.config.ts` sets
95+
`output: 'standalone'`, which causes `next build` to emit a
96+
self-contained `.next/standalone/` containing `server.js`, a
97+
pruned `node_modules` (only modules traced as required at runtime),
98+
and `.next/server/`. Combined with
99+
`productionBrowserSourceMaps: false`, that keeps the runtime image
100+
close to the 100 MB acceptance target without manually curating the
101+
shipped `node_modules`. `.next/static` and `public/` are still copied
102+
in separately because the standalone output deliberately omits them.
103+
104+
2. **Alpine + `libc6-compat`.** Same rationale as the backend image:
105+
Alpine gives the smallest Node base, but Next.js / sharp native
106+
shims link against glibc on some platforms. `libc6-compat` is the
107+
standard musl shim that bridges them without giving up the
108+
≈ 50 MB base size.
109+
110+
3. **No `prod-deps` stage (unlike backend).** Where the backend needs
111+
an explicit `prod-deps` stage because `npm prune --omit=dev` would
112+
otherwise need to run after the fact, Next.js standalone already
113+
traces a production-only `node_modules` into
114+
`.next/standalone/node_modules/` during `next build`. A second
115+
`npm ci --omit=dev` would just duplicate work, so the runner stage
116+
copies the traced tree directly.
117+
118+
4. **Healthcheck via `/api/health`.** `Frontend/src/app/api/health/route.ts`
119+
defines an App Router `GET` handler that returns a tiny
120+
`{ status: 'ok', timestamp }` JSON envelope with
121+
`dynamic = 'force-dynamic'` so the route is never pre-rendered into
122+
the static output (which would break the runtime probe on the
123+
standalone server). `wget --spider` performs a HEAD-style probe
124+
against `http://127.0.0.1:${PORT}/api/health` exactly like the
125+
backend image's `/health` probe.
126+
127+
5. **Layer ordering for cache reuse.** `package.json` +
128+
`package-lock.json` are copied and `npm ci` runs *before* any
129+
application source (`next.config.ts`, `src/`, `public/`) is copied,
130+
so iterating on TypeScript does not invalidate the `node_modules`
131+
cache layer. Config files (`next.config.ts`, `tsconfig.json`) are
132+
copied separately so they live in their own cache layer and can be
133+
invalidated independently of application source.
134+
135+
6. **`npm ci --ignore-scripts`.** Skips postinstall hooks
136+
(`husky prepare`, …) inside the `deps` stage. None of those hooks
137+
are required for `next build` to succeed, and skipping them avoids
138+
installing build-time-only tools (e.g. native binaries that
139+
postinstall scripts copy into `node_modules/.bin/`) into a layer
140+
the runner image doesn't actually use.
141+
75142
## Local validation
76143

144+
### Backend
145+
77146
```bash
78-
# Build each target standalone. The build context MUST be ./Backend
79-
# because `backend.Dockerfile` does relative `COPY package.json ...` and
80-
# `COPY src ./src` — these resolve to Backend/package.json and Backend/src
81-
# only when the context is Backend/, matching how the CI pipeline posts
82-
# `context: ./Backend` to docker/build-push-action.
147+
# Build context MUST be ./Backend because `backend.Dockerfile` does
148+
# relative `COPY package.json ...` and `COPY src ./src` — these resolve
149+
# to Backend/package.json and Backend/src only when the context is
150+
# Backend/, matching how the CI pipeline posts `context: ./Backend` to
151+
# docker/build-push-action.
83152
#
84153
# Issue #6 example commands use repo-root context (`docker build ... .`).
85154
# Those literal invocations are NOT viable with this Dockerfile because
@@ -103,12 +172,55 @@ curl -fsS http://localhost:3000/health
103172
docker inspect --format='{{json .State.Health.Status}}' vertex-backend
104173
```
105174

175+
### Frontend
176+
177+
```bash
178+
# Build context MUST be ./Frontend for the same reason as the backend
179+
# image: `frontend.Dockerfile` does relative `COPY package.json`,
180+
# `COPY src ./src`, and `COPY public ./public`, which only resolve
181+
# correctly when the context is Frontend/.
182+
183+
# Install layer only (useful for debugging `npm ci` failures):
184+
docker build --target deps -f docker/frontend.Dockerfile ./Frontend
185+
186+
# Standalone compilation only (useful for tracing `next build` issues):
187+
docker build --target builder -f docker/frontend.Dockerfile ./Frontend
188+
189+
# Ship-shaped runtime image (target < 100 MB per issue #7):
190+
docker build --target runner -f docker/frontend.Dockerfile ./Frontend
191+
192+
# Boot the runtime image and confirm the healthcheck passes:
193+
docker run --rm -p 3000:3000 --name vertex-frontend \
194+
$(docker build -q --target runner -f docker/frontend.Dockerfile ./Frontend)
195+
sleep 10
196+
curl -fsS http://localhost:3000/api/health # liveness probe
197+
curl -fsS http://localhost:3000/ # landing page renders
198+
docker inspect --format='{{json .State.Health.Status}}' vertex-frontend
199+
```
200+
106201
## Security considerations
107202

203+
### Backend
204+
108205
- Non-root runtime user (`USER node`).
109206
- Production stage installs only `--omit=dev` dependencies and excludes
110207
source maps, dev configs, and `.env` files via `.dockerignore`.
111208
- Image is scanned by Trivy in CI (`infrastructure/ci/docker-build-pipeline.yml`,
112209
`security-scan` job). High or critical CVEs gate the `push` job.
113-
- `TOKEN=` style secret values are never baked into layers: they must be
114-
provided as runtime env vars (`docker run -e KEY=value` or k8s `Secret`).
210+
- `TOKEN=` style secret values are never baked into layers: they must
211+
be provided as runtime env vars (`docker run -e KEY=value` or k8s `Secret`).
212+
213+
### Frontend
214+
215+
- Non-root runtime user (`USER node`, UID 1000).
216+
- Production stage copies only `.next/standalone/`, `.next/static/`, and
217+
`public/` from `builder`. Dev tooling (eslint, vitest, typescript,
218+
husky, …) never enters the runtime image.
219+
- Source maps are not inlined into client bundles
220+
(`productionBrowserSourceMaps: false` in `Frontend/next.config.ts`),
221+
so an attacker pulling the image cannot reconstruct the original
222+
source from client-side bundles.
223+
- Image is scanned by Trivy in CI; high or critical CVEs gate the push.
224+
- `NEXT_PUBLIC_*` style values are intentionally *baked in* — that is
225+
the framework contract for browser-visible env vars. Any secret that
226+
must remain server-only belongs on the backend image, not here.

docker/frontend.Dockerfile

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# syntax=docker/dockerfile:1.7
2+
#
3+
# VertexChain Frontend — multi-stage Dockerfile
4+
#
5+
# Four stages, each contributing once:
6+
# base – shared runtime root (Alpine + libc6-compat + tini)
7+
# deps – full dev + prod deps so `npm run build` can succeed
8+
# builder – Next.js standalone build (output: 'standalone')
9+
# runner – minimal runtime image: standalone output only, non-root,
10+
# HEALTHCHECK against /api/health on port 3000.
11+
#
12+
# The runtime image is built from `runner`, which inherits a clean
13+
# `base` so dev tooling (eslint, vitest, typescript, …) never enters
14+
# the published image. `.next/standalone/` carries a pruned
15+
# `node_modules` produced by Next.js's output-file-tracing pass, which
16+
# is why this Dockerfile does not need a separate `prod-deps` stage
17+
# like `docker/backend.Dockerfile` does.
18+
#
19+
# Acceptance commands (issue #7):
20+
# docker build --target runner -f docker/frontend.Dockerfile ./Frontend
21+
# docker run --rm -p 3000:3000 --name vertex-frontend \
22+
# $(docker build -q --target runner -f docker/frontend.Dockerfile ./Frontend)
23+
# curl -fsS http://localhost:3000/api/health
24+
25+
ARG NODE_VERSION=20
26+
27+
# =============================================================================
28+
# base — minimal Alpine layer reused by every stage.
29+
# • tini: proper PID 1 + signal forwarding, same rationale as backend.
30+
# • libc6-compat: Next.js / sharp native shims link against glibc;
31+
# Alpine ships musl, so the compat layer is needed at runtime.
32+
# `--no-cache` keeps the apk index out of the image.
33+
# =============================================================================
34+
FROM node:${NODE_VERSION}-alpine AS base
35+
WORKDIR /usr/src/app
36+
RUN apk add --no-cache libc6-compat tini \
37+
&& chown node:node /usr/src/app
38+
ENTRYPOINT ["/sbin/tini", "--"]
39+
40+
# =============================================================================
41+
# deps — install every dependency needed by `next build`, including
42+
# devDependencies (typescript, eslint, …). Cached independently so
43+
# editing application source never invalidates this heavy `node_modules`
44+
# layer. `--ignore-scripts` skips postinstall hooks (autoprefixer,
45+
# husky, …) since none of them are required for the standalone build.
46+
# =============================================================================
47+
FROM base AS deps
48+
COPY package.json package-lock.json* ./
49+
RUN npm ci --no-audit --no-fund --ignore-scripts
50+
51+
# =============================================================================
52+
# builder — compile Next.js to `.next/standalone` + `.next/static`.
53+
# • Inherits `deps` (node_modules + Workbox/Turbopack tooling).
54+
# • `NEXT_TELEMETRY_DISABLED=1` opts the build out of anonymous
55+
# telemetry events to https://telemetry.nextjs.org.
56+
# • `npm run build` runs `next build`, which performs output-file
57+
# tracing and produces:
58+
# .next/standalone/ – server.js + traced node_modules + .next/server
59+
# .next/static/ – hashed client bundles (kept separately)
60+
# public/ – user-authored static assets (kept separately)
61+
# =============================================================================
62+
FROM deps AS builder
63+
ENV NEXT_TELEMETRY_DISABLED=1
64+
COPY next.config.ts tsconfig.json ./
65+
COPY src ./src
66+
COPY public ./public
67+
RUN npm run build
68+
69+
# =============================================================================
70+
# runner — minimal runtime image.
71+
# • Fresh `base` so dev tooling, source maps, and `.git` never infect
72+
# the shipped image.
73+
# • `node` user (UID 1000, ships with node:20-alpine) — non-root,
74+
# satisfying the issue #7 "must not run as root" criterion.
75+
# • `--chown=node:node` is folded into the COPYs so we do not add an
76+
# extra layer just to chown files.
77+
# • The standalone directory is the only thing copied from `builder`.
78+
# Its top-level layout is:
79+
# ./
80+
# server.js <- the entry point we run with `node`
81+
# package.json
82+
# node_modules/ <- pruned by next's tracing pass
83+
# .next/server/ <- server-side bundle
84+
# Files outside that subtree still need to be copied in
85+
# separately (`.next/static`, `public/`).
86+
# =============================================================================
87+
FROM base AS runner
88+
ENV NODE_ENV=production
89+
ENV PORT=3000
90+
ENV NEXT_TELEMETRY_DISABLED=1
91+
92+
# Standalone bundle: server.js, package.json, traced node_modules,
93+
# .next/server. The trailing `/` is required so COPY targets the
94+
# directory contents rather than the directory itself.
95+
COPY --from=builder --chown=node:node /usr/src/app/.next/standalone ./
96+
# Static assets hashed at build time (immutable, served by Next.js).
97+
COPY --from=builder --chown=node:node /usr/src/app/.next/static ./.next/static
98+
# User-authored public/ assets (favicon, robots.txt, etc.).
99+
COPY --from=builder --chown=node:node /usr/src/app/public ./public
100+
101+
USER node
102+
103+
EXPOSE 3000
104+
105+
# Liveness probe against the App Router endpoint defined in
106+
# `Frontend/src/app/api/health/route.ts`. `wget --spider` is a HEAD-style
107+
# probe that doesn't save a response body, which matters because some
108+
# App Router responses are streamed and the connection shouldn't be
109+
# drained to disk. `start-period=10s` gives Next.js time to compile the
110+
# server entry and bind the listener before the first probe.
111+
#
112+
# NOTE: this is the SHELL form of HEALTHCHECK CMD (no `[]` around the
113+
# arguments). Docker runs it via `sh -c`, which is what lets `${PORT}`
114+
# resolve at container runtime.
115+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
116+
CMD wget --quiet --tries=1 --spider \
117+
http://127.0.0.1:${PORT}/api/health || exit 1
118+
119+
# `next build` with `output: 'standalone'` emits a top-level
120+
# `server.js` that listens on $PORT (default 3000). Running it under
121+
# `tini` (ENTRYPOINT) ensures SIGTERM from Kubernetes propagates cleanly.
122+
CMD ["node", "server.js"]

0 commit comments

Comments
 (0)