Files
spa/.planning/phase-1-foundation/09-gitea-ci-and-dockerfile.md
T
julian 26e059fc20 feat: planning structure + task 1.2 stack rounding-out
Add .planning/ scaffolding:
- ROADMAP.md (4 phases, 8 non-negotiable design rules)
- phase-1-foundation/ README + 9 task files (1.2-1.10)
- phase-2-live-map / phase-3-dogfood-readiness / phase-4-future README placeholders

Task 1.2 — stack rounding-out:
- Tailwind 4 via @tailwindcss/vite + src/styles/globals.css
- shadcn/ui (slate, new-york) primitives in src/ui/primitives/:
  button, input, label, form, card, alert
- TanStack Router 1.169 + Query 5.100 (devtools + plugin in devDeps)
- Zustand 5, @directus/sdk 21, zod 4, react-hook-form 7 + resolvers
- Prettier 3 + eslint-config-prettier + eslint-plugin-prettier
- ESLint override disabling react-refresh/only-export-components for
  src/ui/primitives/** (intentional dual-exports in shadcn primitives)
- Path alias @/* -> ./src/* in tsconfig.json + tsconfig.app.json
  (TS 6 deprecates baseUrl; paths now resolve relative to config file).
  Pulled forward from 1.3 because shadcn add CLI needs it resolvable.
- Scripts: dev, build, preview, lint, typecheck, format, format:check,
  test (placeholder)
- App.tsx Tailwind smoke test (centred card + shadcn Button)
- README.md rewritten with stack/scripts/shadcn-add docs

All four gates green: typecheck, lint, format:check, build (222KB / 70KB gz).
2026-05-02 18:41:54 +02:00

7.7 KiB

Task 1.9 — Gitea CI + Dockerfile + nginx static serve

Phase: 1 — Foundation Status: Not started Depends on: 1.2 (need build to be working) Wiki refs: docs/wiki/entities/react-spa.md; trm/processor/.gitea/workflows/build.yml and trm/directus/.gitea/workflows/build.yml for pattern alignment

Goal

Build a Docker image of the SPA (static bundle served by nginx), publish to the Gitea container registry on every push to main, matching the CI conventions established by trm/processor and trm/directus. Include lint + typecheck + format-check + build as the gate before image publish.

After this task, pushing to main produces a git.dev.microservices.al/trm/spa:main image that trm/deploy can pull.

Deliverables

  • Dockerfile:
    • Multi-stage: builder (node:22-alpine, install deps with pnpm, run build) → runner (nginx:alpine, copy dist/ to /usr/share/nginx/html).
    • BuildKit cache mounts for pnpm fetch + pnpm install --offline (matching the processor's pattern).
    • nginx.conf baked in (next bullet).
    • Listen on :80; the deploy proxy fronts it.
  • nginx.conf:
    • Single server block listening on :80.
    • Static-serve /usr/share/nginx/html with try_files $uri $uri/ /index.html; (SPA routing fallback so /login etc. all serve index.html).
    • location = /config.json block: serve from a separate path that can be volume-mounted in stage/prod (the override path described in 1.4). Default value is the baked-in dev defaults.
    • gzip on for text/css, application/javascript, application/json. (Brotli later if it becomes a concern.)
    • Cache headers: index.html no-cache; everything else (the hashed JS/CSS/sprite assets) Cache-Control: public, max-age=31536000, immutable.
  • .dockerignore:
    • Includes node_modules, dist, .git, .gitea, .planning, *.md other than necessary, etc. Match the existing pattern from processor/directus.
  • .gitea/workflows/build.yml:
    • Triggers: push to main, push to phase-2-* branches.
    • Path filter: src/**, public/**, *.json, *.ts, *.tsx, *.js, *.cjs, *.html, Dockerfile, nginx.conf, .gitea/workflows/build.yml. Skip on docs-only changes.
    • Single job; mirrors trm/processor/.gitea/workflows/build.yml structure.
    • Steps:
      1. Checkout.
      2. Setup pnpm (use the same caching strategy as the processor).
      3. pnpm install --frozen-lockfile.
      4. pnpm typecheck.
      5. pnpm lint.
      6. pnpm format:check.
      7. pnpm build.
      8. Build Docker image (use the BuildKit cache between runs).
      9. Login to git.dev.microservices.al registry.
      10. Push image with :main and per-commit-SHA tags.
  • package.json scripts.test placeholder (Phase 3 will replace; for now, "test": "echo \"no tests yet\" && exit 0" so CI can include the step without failing).
  • README.md updated: "Building locally" + "CI" sections.

Specification

Dockerfile shape

# syntax=docker/dockerfile:1.6

# ───────── builder ─────────
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /build

COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
    pnpm fetch

COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile --offline

RUN pnpm build  # produces dist/

# ───────── runner ─────────
FROM nginx:1.27-alpine AS runner
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /build/dist /usr/share/nginx/html

EXPOSE 80

# nginx default CMD is correct; don't override.

Mirror the multi-stage + cache-mount pattern from trm/processor/Dockerfile precisely. Differences are SPA-specific (no runtime Node, just nginx).

nginx.conf shape

server {
  listen 80;
  server_name _;
  root /usr/share/nginx/html;
  index index.html;

  # SPA routing fallback
  location / {
    try_files $uri $uri/ /index.html;
  }

  # Hashed assets — long cache
  location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
    try_files $uri =404;
  }

  # index.html — never cache
  location = /index.html {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    expires off;
  }

  # Runtime config — overridable via volume mount
  location = /config.json {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    try_files $uri =404;
  }

  # Compression
  gzip on;
  gzip_types text/css application/javascript application/json image/svg+xml;
  gzip_min_length 1024;
}

The location = /config.json placement allows mounting a different file at /usr/share/nginx/html/config.json in stage/prod via Docker volume — that's the override mechanism for runtime config.

Workflow path filter

Mirror processor's filter structure. Critical paths to include:

on:
  push:
    branches:
      - main
      - 'phase-*-**'
    paths:
      - 'src/**'
      - 'public/**'
      - 'package.json'
      - 'pnpm-lock.yaml'
      - 'index.html'
      - 'tsconfig*.json'
      - 'vite.config.ts'
      - 'tailwind.config.ts'
      - 'eslint.config.js'
      - '.prettierrc'
      - 'Dockerfile'
      - 'nginx.conf'
      - '.dockerignore'
      - '.gitea/workflows/build.yml'

Docs-only changes (.planning/, README.md alone) skip CI.

Image tagging

Two tags per push to main:

  1. :main — moves with each main commit. Used by stage's compose.yaml for "always latest."
  2. :<short-sha> — pinned reference. Production pins to a specific SHA to avoid surprise rollouts.

This matches the processor's tagging scheme. trm/deploy/compose.yaml already documents the *_TAG env var pattern; Phase 1.10 adds SPA_TAG.

Why nginx not a Node static server

A 5MB static bundle doesn't need a Node runtime. nginx is:

  • Faster (compiled C, not JS).
  • Smaller image (~20MB vs ~150MB for node:22-alpine + serve).
  • Battle-tested for static-serving + SPA-routing.

Match the processor's "right tool for the job" discipline.

CI step ordering

typecheck before lint before format:check before build is intentional — the cheapest checks first, so a broken typecheck fails the build before lint/format burn cycles. build is last because it's the most expensive and is what actually goes into the image.

Acceptance criteria

  • docker build -t trm-spa-test . succeeds locally.
  • docker run -p 8080:80 trm-spa-test serves the SPA at http://localhost:8080; refreshing on /login (after 1.7) serves index.html (SPA fallback works).
  • pnpm format:check script exists and is green.
  • On push to main in Gitea, the workflow runs all gates and pushes the image to the registry.
  • The image is < 50MB total.
  • On push that only touches .planning/ or README.md, CI is skipped.
  • Per-commit SHA tag is present in the registry alongside :main.

Risks / open questions

  • Pinning pnpm version. Use corepack prepare pnpm@<version> --activate with the version pinned to whatever package.json packageManager says, if set. Otherwise pin to the minor (pnpm@9 or whatever the team is on) to avoid surprises.
  • Build-time env vars. None should bleed in. Verify by docker run trm-spa-test cat /usr/share/nginx/html/index.html and grep for any hardcoded localhost URL — if found, that's a runtime-config violation to chase.
  • Caching the pnpm store across CI runs. Gitea Actions runners differ; the --mount=type=cache,id=pnpm-store only helps during a single build. For cross-run caching, use Gitea Actions' actions/cache@v4 step, keyed on pnpm-lock.yaml hash. Match what processor does — it likely already has this pattern.

Done

(Filled in when the task lands.)