Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions Frontend/.dockerignore
Original file line number Diff line number Diff line change
@@ -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-*
12 changes: 11 additions & 1 deletion Frontend/next.config.ts
Original file line number Diff line number Diff line change
@@ -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;
26 changes: 26 additions & 0 deletions Frontend/src/app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -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(),
});
}
138 changes: 125 additions & 13 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: ./<workspace>`. 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`

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
122 changes: 122 additions & 0 deletions docker/frontend.Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
Loading