diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index bae5ca5..d44abe7 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -25,6 +25,17 @@ jobs:
- name: "Frontend served"
run: curl -sf http://localhost:8080/ | grep -q "
React Template"
+ # Locks in the fallback rule from frontend/docker/nginx.conf: a browser
+ # navigating to a client-side route gets the shell, a missing asset does
+ # not (a 200 there hides the 404 behind a parse error downstream).
+ - name: "SPA fallback: client routes get index.html, missing assets 404"
+ run: |
+ curl -sf -H 'Accept: text/html' http://localhost:8080/about \
+ | grep -q "React Template"
+ code=$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/assets/missing.js)
+ echo "missing asset -> $code"
+ test "$code" = 404
+
# One request is enough to prove the wiring: reaching /health under /api
# means the image runs and nginx strips the prefix. The endpoints
# themselves are the Contract workflow's job.
diff --git a/frontend/docker/nginx.compose.conf b/frontend/docker/nginx.compose.conf
index e5af44f..5793817 100644
--- a/frontend/docker/nginx.compose.conf
+++ b/frontend/docker/nginx.compose.conf
@@ -29,8 +29,27 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
- # SPA fallback: let React Router handle any unknown path.
+ # SPA fallback: unknown paths belong to React Router, so browser
+ # navigation gets index.html. Everything else keeps its 404 — answering a
+ # missing asset or a mistyped fetch() path with the HTML shell and a 200
+ # turns a clear "not found" into a confusing parse error further down.
+ # Same rule FastAPI's app.frontend() applies: only GET/HEAD requests that
+ # ask for HTML get the shell, which is what a browser sends when
+ # navigating (so `curl /some/route` with no Accept header 404s by design).
location / {
- try_files $uri $uri/ /index.html;
+ try_files $uri $uri/ @spa;
+ }
+
+ location @spa {
+ if ($request_method !~ ^(GET|HEAD)$) {
+ return 404;
+ }
+ if ($http_accept !~* "text/html|application/xhtml\+xml") {
+ return 404;
+ }
+ # Served straight from this location, so repeat the no-cache header
+ # that `location = /index.html` above sets for the direct request.
+ add_header Cache-Control "no-cache";
+ try_files /index.html =404;
}
}
diff --git a/frontend/docker/nginx.conf b/frontend/docker/nginx.conf
index c06bbda..5575426 100644
--- a/frontend/docker/nginx.conf
+++ b/frontend/docker/nginx.conf
@@ -29,8 +29,27 @@ server {
# proxy_set_header X-Forwarded-Proto $scheme;
# }
- # SPA fallback: let React Router handle any unknown path.
+ # SPA fallback: unknown paths belong to React Router, so browser
+ # navigation gets index.html. Everything else keeps its 404 — answering a
+ # missing asset or a mistyped fetch() path with the HTML shell and a 200
+ # turns a clear "not found" into a confusing parse error further down.
+ # Same rule FastAPI's app.frontend() applies: only GET/HEAD requests that
+ # ask for HTML get the shell, which is what a browser sends when
+ # navigating (so `curl /some/route` with no Accept header 404s by design).
location / {
- try_files $uri $uri/ /index.html;
+ try_files $uri $uri/ @spa;
+ }
+
+ location @spa {
+ if ($request_method !~ ^(GET|HEAD)$) {
+ return 404;
+ }
+ if ($http_accept !~* "text/html|application/xhtml\+xml") {
+ return 404;
+ }
+ # Served straight from this location, so repeat the no-cache header
+ # that `location = /index.html` above sets for the direct request.
+ add_header Cache-Control "no-cache";
+ try_files /index.html =404;
}
}