Skip to content

Scaffold web Dockerfile + wire SPA into docker-compose - #27

Closed
mforce wants to merge 1 commit into
mainfrom
feat/web-docker
Closed

mforce wants to merge 1 commit into
mainfrom
feat/web-docker

Conversation

@mforce

@mforce mforce commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Containerizes the React/Vite SPA and adds it to the deploy stack. The backend Dockerfile already existed; this completes the picture.

Changes

  • web/Dockerfile — multi-stage: node:22-alpine build (npm ci && npm run build) → nginx:1.27-alpine serving static dist/.
  • web/nginx.conf — SPA client-route fallback (try_files → /index.html), gzip, immutable caching for hashed /assets/, no-cache on index.html so deploys are picked up.
  • deploy/docker-compose.yml — new web service; traefik routing split so the API owns Host && PathPrefix(/api) (priority 10) and the web catch-all serves everything else (priority 1). The SPA's relative /api/v1 calls route to the api service — same contract as the Vite dev proxy, so no build-time API URL needed.
  • .dockerignore (root + web/) — keep both build contexts lean.

Verification

  • docker build (web) passes.
  • Container smoke test: / → 200 index, /stock client route → 200 (SPA fallback), hashed assets carry Cache-Control: public, immutable.

Supports #20 (SPA) and the deploy story. No app-code changes.

- web/Dockerfile: multi-stage node build -> nginx static serve.
- web/nginx.conf: SPA client-route fallback (try_files -> index.html),
  gzip, immutable caching for hashed /assets, no-cache index.html.
- compose: add web service; split traefik routing so the API owns
  Host + PathPrefix(/api) (priority 10) and the web catch-all serves
  everything else (priority 1). The SPA's relative /api/v1 calls route
  to the api service — same contract as the Vite dev proxy.
- .dockerignore (root + web/): keep API and web build contexts lean.

Verified: docker build passes; container serves index (200), SPA
fallback for client routes (/stock -> 200), assets carry immutable
cache headers.
@mforce

mforce commented Jul 15, 2026

Copy link
Copy Markdown
Owner Author

Superseded — switching to single-container (SPA served by the API via wwwroot + MapFallbackToFile). Simpler deploy, zero CORS surface. New PR incoming.

@mforce mforce closed this Jul 15, 2026
mforce added a commit that referenced this pull request Jul 15, 2026
* Serve the SPA from the API (single-container)

Instead of a separate nginx container, the API now serves the built
React/Vite SPA from wwwroot. One image, one deploy, one origin -> no CORS
surface, relative /api/v1 calls are same-origin by construction.

- Program.cs: UseDefaultFiles + UseStaticFiles (public, before auth) and
  MapFallbackToFile(index.html) at lowest route priority, so /api/v1/*
  and /health always match first and unknown paths fall back to the SPA
  for client-side routing. No-op in dev (Vite serves the SPA there).
- src/Cluckwork.Api/Dockerfile: added a node:22-alpine web-build stage
  (npm ci + npm run build) whose dist/ is copied into wwwroot in the
  final image.
- .dockerignore: keep the root build context lean (web/ source is now
  built in-image; node_modules/dist/bin/obj excluded).

compose stays single-router (Host -> :8080). Supersedes the two-container
approach (closed PR #27).

Verified: docker build produces an image with the SPA in wwwroot; all 10
API integration tests pass with the new middleware in the pipeline.

* Accept JWT PEM keys with escaped newlines from env

.env files and env vars cannot hold real line breaks, so JWT keys are
supplied with literal \n. RSA.ImportFromPem needs actual newlines —
add PemKey.Normalize (\n -> newline, CRLF -> LF) and apply it at both
import sites (public key in Program, private key in JwtTokenService).

Unblocks docker compose, where Jwt__PublicKeyPem / Jwt__PrivateKeyPem
come from deploy/.env. Verified: build clean, 10 integration tests pass
(they sign + validate real tokens).

* Make local docker dev work: publish API :8080, traefik prod-only

- api: publish 8080:8080 for direct local access (http://localhost:8080
  serves SPA + API), independent of traefik.
- traefik: gate behind a 'prod' compose profile so a bare
  'docker compose up' runs only api + db — no traefik. Docker Engine 29
  rejects traefik's docker-provider API negotiation ('client version 1.24
  too old'), which is irrelevant for local dev. Enable in prod with
  'docker compose --profile prod up'.
- bump traefik v3.1 -> v3.5.

Verified: stack up, http://localhost:8080/ serves the SPA (200),
/stock falls back to index (200), /health/live 200. Login is 500 until
migrations + seed land (#5: relation "durable_jobs" does not exist).

* Bind local API port to loopback only (127.0.0.1:8080)

Security review: publishing 8080:8080 binds 0.0.0.0, exposing the
plaintext (no-TLS) API to the LAN. Bind to 127.0.0.1 so local dev access
stays on the host. Prod uses the traefik/TLS 'prod' profile.

Verified: http://127.0.0.1:8080/ -> 200; docker port mapping shows
127.0.0.1:8080->8080/tcp.

* Rename compose service api -> app (now bundles API + SPA)

The service builds an image that serves both the JSON API and the SPA
from wwwroot, so 'app' describes it better than 'api'. No code references
the service name (DB uses Host=db); traefik discovers by container label,
so labels are unchanged.

Verified: docker compose config OK; deploy-app-1 serves / -> 200.

---------

Co-authored-by: mforce <>
@mforce
mforce deleted the feat/web-docker branch July 15, 2026 05:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant