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
18 changes: 18 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Build outputs and local dependencies are recreated in the builder stage.
node_modules
.next
coverage
playwright-report
test-results

# Local configuration can contain secrets and must never enter an image build.
.env
.env.*

# Repository/editor metadata and non-production developer artifacts.
.git
.gitignore
*.log
*.md
tests
src/__tests__
2 changes: 2 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Local development backend. Omit NEXT_PUBLIC_API_URL in a production build so
# browser requests stay on the public origin and Caddy can route `/api/*`.
NEXT_PUBLIC_API_URL=http://localhost:5147
# reCAPTCHA v2 site key (public) — get from google.com/recaptcha/admin
NEXT_PUBLIC_RECAPTCHA_SITE_KEY=REPLACE_WITH_RECAPTCHA_V2_SITE_KEY
Expand Down
14 changes: 14 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
development-dependencies:
dependency-type: development
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
95 changes: 80 additions & 15 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,26 @@ on:
pull_request:
branches: ["main"]

# The normal validation job is deliberately read-only. The separate evidence
# attestation job gets its write permissions only after validation succeeds.
permissions:
contents: read

jobs:
frontend:
name: Lint, build, test, audit
name: Build, test, package, and container smoke
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Set up Node 20
uses: actions/setup-node@v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: package-lock.json

- name: Install dependencies
- name: Install dependencies reproducibly
run: npm ci

- name: Type check
Expand All @@ -30,17 +34,78 @@ jobs:
- name: Lint
run: npm run lint

- name: Build
- name: Build production frontend
run: npm run build
env:
NEXT_PUBLIC_API_URL: http://localhost:5147
NEXT_PUBLIC_RECAPTCHA_SITE_KEY: placeholder
NEXT_PUBLIC_GOOGLE_CLIENT_ID: placeholder
# Deliberately omit NEXT_PUBLIC_API_URL. Production browser traffic
# must stay on the Caddy public origin and use relative /api routes.
NEXT_PUBLIC_RECAPTCHA_SITE_KEY: placeholder-public-key
NEXT_PUBLIC_GOOGLE_CLIENT_ID: placeholder-public-client-id

- name: Tests
- name: Unit and component tests
run: npm test

# npm audit may report advisories for transitive dependencies (e.g. PostCSS via Next.js)
# that have no safe upgrade path. Run informational-only so it does not block CI.
- name: npm audit (informational)
run: npm audit --audit-level=critical || true
- name: Fail on high or critical npm vulnerabilities
run: npm audit --audit-level=high

- name: Generate reproducible CycloneDX SBOM
run: |
mkdir -p artifacts/sbom
npx --yes @cyclonedx/cyclonedx-npm@6.0.0 \
--package-lock-only \
--output-reproducible \
--output-format JSON \
--output-file artifacts/sbom/frontend.cdx.json

- name: Build production container image
run: |
docker build \
--build-arg NEXT_PUBLIC_RECAPTCHA_SITE_KEY=placeholder-public-key \
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID=placeholder-public-client-id \
--tag simple-frontend:${{ github.sha }} .

- name: Smoke the unprivileged container
shell: bash
run: |
set -euo pipefail
docker run --detach --name simple-frontend-smoke -p 3000:3000 simple-frontend:${{ github.sha }}
for attempt in {1..30}; do
if curl --fail --silent --show-error http://127.0.0.1:3000/ >/dev/null; then
docker exec simple-frontend-smoke id | grep -q 'uid=1001(nextjs)'
exit 0
fi
sleep 1
done
docker logs simple-frontend-smoke
exit 1

- name: Remove smoke container
if: always()
run: docker rm --force simple-frontend-smoke || true

- name: Upload CI evidence
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-evidence-${{ github.sha }}
path: artifacts/sbom/
if-no-files-found: error
retention-days: 30

attest-sbom:
name: Attest frontend SBOM evidence
needs: frontend
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
attestations: write
id-token: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: frontend-evidence-${{ github.sha }}
path: evidence

- uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: evidence/frontend.cdx.json
25 changes: 25 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: CodeQL

on:
push:
branches: ["main", "feature/**"]
pull_request:
branches: ["main"]
schedule:
- cron: "23 3 * * 1"

permissions:
contents: read
security-events: write

jobs:
analyze:
name: Analyze JavaScript and TypeScript
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: github/codeql-action/init@24ea975727876cf496b1eb0c5b36e96e01600b51 # v4.37.0
with:
languages: javascript-typescript
build-mode: none
- uses: github/codeql-action/analyze@24ea975727876cf496b1eb0c5b36e96e01600b51 # v4.37.0
57 changes: 57 additions & 0 deletions .github/workflows/publish-image.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Publish immutable frontend image

on:
workflow_dispatch:

# Run this only for an already-green commit on main. The resulting digest and
# provenance attestation are release evidence; a mutable tag is never enough.
permissions:
contents: read
packages: write
attestations: write
id-token: write

jobs:
publish:
name: Publish and attest frontend image
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: azure-demo
env:
IMAGE_NAME: ghcr.io/simpleplatform/simple-frontend
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0

- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push immutable candidate
id: push
uses: docker/build-push-action@ee4ca427a2f43b6a16632044ca514c076267da23 # v6.19.0
with:
context: .
push: true
tags: ${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
build-args: |
NEXT_PUBLIC_RECAPTCHA_SITE_KEY=${{ vars.NEXT_PUBLIC_RECAPTCHA_SITE_KEY }}
NEXT_PUBLIC_GOOGLE_CLIENT_ID=${{ vars.NEXT_PUBLIC_GOOGLE_CLIENT_ID }}
labels: |
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}

- name: Attest image build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-name: ${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

- name: Record digest in job summary
run: |
echo '### Immutable frontend image' >> "$GITHUB_STEP_SUMMARY"
echo "\`${IMAGE_NAME}@${{ steps.push.outputs.digest }}\`" >> "$GITHUB_STEP_SUMMARY"
44 changes: 44 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# syntax=docker/dockerfile:1

FROM node:22-alpine AS dependencies
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY package.json package-lock.json ./
RUN npm ci

FROM node:22-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .

# These are browser-visible values, not secrets. Next.js embeds NEXT_PUBLIC_*
# values into the generated client bundle during this build stage.
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_RECAPTCHA_SITE_KEY
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG SIMPLE_API_PROXY_TARGET
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_PUBLIC_RECAPTCHA_SITE_KEY=${NEXT_PUBLIC_RECAPTCHA_SITE_KEY}
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=${NEXT_PUBLIC_GOOGLE_CLIENT_ID}
ENV SIMPLE_API_PROXY_TARGET=${SIMPLE_API_PROXY_TARGET}

RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0

RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs

COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,37 @@ The backend API must be running at `NEXT_PUBLIC_API_URL` (default `http://localh

| Variable | Description |
|---|---|
| `NEXT_PUBLIC_API_URL` | Backend API base URL |
| `NEXT_PUBLIC_API_URL` | Backend API base URL in local development. Omit it from a production build to use the browser's own origin. |
| `NEXT_PUBLIC_RECAPTCHA_SITE_KEY` | Google reCAPTCHA v2 site key |
| `NEXT_PUBLIC_GOOGLE_CLIENT_ID` | Google OAuth client ID |

## Container and same-origin delivery

The production image runs the standalone Next.js server as an unprivileged
user on port `3000`.

```sh
docker build -t simple-frontend \
--build-arg NEXT_PUBLIC_RECAPTCHA_SITE_KEY=your-public-site-key \
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID=your-public-client-id \
.
docker run --rm -p 3000:3000 simple-frontend
```

`NEXT_PUBLIC_*` values are embedded during `next build`; only use public browser
configuration as build arguments. Never pass server credentials, API keys, or
secrets to this image.

In production, leave `NEXT_PUBLIC_API_URL` unset. The frontend then calls
relative `/api/*` URLs, allowing the public Caddy gateway to route those
requests to the backend while keeping session cookies first-party. Caddy also
owns future SignalR/WebSocket routes; this frontend does not proxy them.

For an isolated frontend container smoke test without Caddy, pass the
server-only `SIMPLE_API_PROXY_TARGET` build argument (for example,
`--build-arg SIMPLE_API_PROXY_TARGET=http://backend:8080`). Next.js will
rewrite `/api/*` to that internal target. Do not set it in the Caddy deployment.

## Running tests

```
Expand Down
20 changes: 20 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import type { NextConfig } from "next";
import path from "path";

/**
* Server-only escape hatch for running the frontend without the public Caddy
* gateway (for example, an isolated container smoke test). In the deployed
* stack, Caddy owns the same-origin `/api/*` route and this is intentionally
* left unset.
*/
const apiProxyTarget = process.env.SIMPLE_API_PROXY_TARGET?.replace(/\/+$/, "");

const securityHeaders = [
// Prevent browsers from MIME-sniffing away from the declared Content-Type.
{ key: "X-Content-Type-Options", value: "nosniff" },
Expand All @@ -15,7 +23,19 @@ const securityHeaders = [
];

const nextConfig: NextConfig = {
// Produces the minimal runtime server used by the production Docker image.
output: "standalone",
outputFileTracingRoot: path.resolve(__dirname),
async rewrites() {
if (!apiProxyTarget) return [];

return [
{
source: "/api/:path*",
destination: `${apiProxyTarget}/api/:path*`,
},
];
},
async headers() {
return [
{
Expand Down
Loading
Loading