Docker and CI
This page covers the production Docker build, the CI pipeline configuration, and the docs deployment workflow.
Docker
Dockerfile
The Dockerfile uses a multi-stage build to produce a minimal production image. It lives at the repository root.
Stage 1: deps -- Install dependencies in an isolated layer:
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/
COPY libs/shared/package.json libs/shared/
COPY libs/data-access/package.json libs/data-access/
COPY libs/verification/package.json libs/verification/
COPY libs/editor/package.json libs/editor/
RUN pnpm install --frozen-lockfileOnly package.json files are copied first to maximize Docker layer caching. Dependencies are only reinstalled when lockfile or package manifests change.
Stage 2: build -- Copy source and build:
FROM node:22-alpine AS build
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY --from=deps /app/libs/shared/node_modules ./libs/shared/node_modules 2>/dev/null || true
COPY --from=deps /app/libs/data-access/node_modules ./libs/data-access/node_modules 2>/dev/null || true
COPY --from=deps /app/libs/verification/node_modules ./libs/verification/node_modules 2>/dev/null || true
COPY --from=deps /app/libs/editor/node_modules ./libs/editor/node_modules 2>/dev/null || true
COPY . .
RUN pnpm buildThe 2>/dev/null || true on library node_modules copies handles cases where pnpm does not create a local node_modules (hoisting to root instead).
Stage 3: runtime -- Minimal production image:
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=build /app/apps/web/.next/standalone ./
COPY --from=build /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=build /app/apps/web/public ./apps/web/public
EXPOSE 3000
CMD ["node", "apps/web/server.js"]The runtime stage only copies the standalone Next.js output, static assets, and public files. No node_modules directory or source code. The resulting image is typically 100-200MB.
Standalone Output
The next.config.js includes output: 'standalone' which tells Next.js to produce a self-contained server:
// next.config.js (relevant section)
module.exports = {
output: 'standalone',
// ...
};This generates apps/web/.next/standalone/ containing a minimal Node.js server (server.js) and only the node_modules actually needed at runtime.
docker-compose.yml
The compose file runs the web service on port 3000 with a healthcheck:
services:
web:
build: .
ports:
- '3000:3000'
env_file:
- .env.production
restart: unless-stopped
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3000/api/health']
interval: 30s
timeout: 5s
retries: 3
start_period: 10sAlpine-based images include wget but not curl, so the healthcheck uses wget --spider (HEAD-only request).
Health Endpoint
The health endpoint at GET /api/health returns:
{
"status": "ok",
"timestamp": 1708646400000
}The timestamp is the server's Date.now() value in epoch milliseconds. This endpoint is used by Docker healthcheck and external monitoring.
Environment Setup
Copy the example file and fill in values before running:
cp .env.production.example .env.productionThe .env.production.example contains the required environment variables for Spring Boot (database credentials, GitHub OAuth, ADMIN_SECRET) and Next.js (SPRING_BOOT_URL, FRONTEND_URL). See the Spring Boot Backend page for the full variable list.
Running
# Build the production image
docker compose build
# Start the container
docker compose up
# Start in detached mode
docker compose up -d
# Check health status
docker compose psCI Pipeline
The CI pipeline is defined in .github/workflows/ci.yml. It runs on every push and pull request to main.
Configuration
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: trueThe concurrency block ensures that only one CI run is active per PR or branch. New pushes cancel in-progress runs.
Steps
The pipeline runs as a single job on ubuntu-latest with Node.js 22:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # full history for Nx affected
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Set Nx SHAs
uses: nrwl/nx-set-shas@v4
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Validate packs
run: pnpm validate
- name: Lint (affected)
run: npx nx affected --target=lint
- name: Typecheck (all)
run: npx nx run-many --target=typecheck
- name: Test (affected)
run: npx nx affected --target=test -- --run
- name: Build (affected)
run: npx nx affected --target=build
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: E2E tests
run: npx nx e2e @nthtime/web
- name: Docker build (verify)
run: docker build -t nthtime-web .Step Breakdown
Checkout with
fetch-depth: 0-- full git history is required for Nx to compute affected packages by comparing against the base SHA.Setup pnpm and Setup Node.js -- configures the runtime with pnpm caching.
Set Nx SHAs (
nrwl/nx-set-shas@v4) -- determines the base and head SHAs fornx affectedcommands. On PRs, it uses the merge base withmain. On pushes tomain, it uses the previous commit.Install dependencies with
--frozen-lockfile-- ensures reproducible installs matching the lockfile exactly.Validate packs -- runs
tools/validate-packs.tsto verify all challenge reference solutions pass their assertions. This runs before other targets because pack validation is independent of Nx.Lint (affected) -- runs ESLint only on packages that changed since the base SHA.
Typecheck (all) -- runs TypeScript type checking on all packages (not just affected). This catches cross-package type errors that
affectedmight miss.Test (affected) -- runs Vitest on affected packages with
--run(single-run mode, no watch).Build (affected) -- builds only affected packages (primarily
apps/web).Install Playwright browsers -- installs Chromium for E2E tests.
E2E tests -- runs Playwright tests against the dev server using mock data.
Docker build (verify) -- builds the Docker image to verify the Dockerfile is valid. Does not push.
Nx Affected
The nx affected command compares the current head against a base SHA to determine which packages have changed. Only those packages (and their dependents) run the specified target. This dramatically reduces CI time on large monorepos.
# Only lint packages that changed
npx nx affected --target=lint
# Only test packages that changed
npx nx affected --target=test -- --run
# Only build packages that changed
npx nx affected --target=buildThe base SHA is set automatically by nrwl/nx-set-shas@v4. You can verify it locally:
# See what would be affected compared to main
npx nx affected --target=test --base=main --head=HEADDocs Deployment
Documentation is deployed separately via .github/workflows/docs.yml. This workflow only triggers on pushes to main that change files under docs/:
name: Deploy Docs
on:
push:
branches: [main]
paths: ['docs/**']
workflow_dispatch:The workflow_dispatch trigger allows manual deployment from the GitHub Actions UI.
Build and Deploy
The docs workflow builds the VitePress site and deploys to GitHub Pages:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build docs
run: pnpm docs:build
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/.vitepress/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4The workflow requires the repository to have GitHub Pages enabled with the "GitHub Actions" source. The permissions block grants the required pages: write and id-token: write for the deployment.
Concurrency
concurrency:
group: pages
cancel-in-progress: falseUnlike CI, the docs deployment uses cancel-in-progress: false to avoid interrupting an active deployment. Subsequent deployments queue until the current one completes.