1. Introduction
You deploy a React, Vue, or Angular single-page application behind Nginx, everything works on the root path, but refreshing or directly linking to any sub-route — /dashboard, /users/123, /settings — returns a 404. The application itself is fine; Nginx is looking for a physical file at that path, finds nothing, and serves its default 404 page instead of your index.html.
This is one of the most common Nginx configuration mistakes for SPAs, and the fix is a single config change — but the details depend on whether you're running Nginx as a standalone server, in a Docker container, or as a Kubernetes Ingress.
2. What This Error Actually Means
In a traditional multi-page application, every URL corresponds to a file on disk. /about serves /var/www/html/about.html. Nginx's default behaviour is exactly that: try to find a file matching the request path, and return 404 if it doesn't exist.
Single-page applications don't work this way. There's one HTML file — index.html — and the JavaScript router intercepts URL changes and renders different views without making new server requests. But when someone directly visits /dashboard or hits refresh, the browser sends a real HTTP request to Nginx for that path. Nginx looks for a dashboard file or directory, finds neither, and returns 404. The JavaScript router never gets a chance to run.
3. Common Causes
- Default Nginx config has no
try_filesdirective — it only serves exact file matches try_filesis configured but in the wronglocationblock- Docker container uses a base Nginx image with the default config and no SPA-specific overrides
- Kubernetes Ingress is passing paths through to the container without the
try_filesfallback - The Nginx config has a
location /api/block for backend proxying, but the fallback only exists in the rootlocation /block - Subpath deployment (app hosted at
/app/) with incorrectbasetag inindex.html
4. Step-by-Step Fix
Step 1: Confirm this is an SPA routing issue, not a missing file
# Test a known working path vs a deep route
curl -I http://localhost/ # should return 200
curl -I http://localhost/dashboard # returns 404 = SPA routing issue
curl -I http://localhost/static/main.js # should return 200 if assets exist
# Check what Nginx is actually returning
curl -v http://localhost/dashboard 2>&1 | grep -E "HTTP|Server|<"
Step 2: Fix — Standalone Nginx server block
The core fix is adding try_files $uri $uri/ /index.html to your root location block. This tells Nginx to: try the exact URI, then try it as a directory, and if neither exists, serve index.html — letting the JavaScript router take over.
server {
listen 80;
server_name example.com;
root /var/www/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Separate API proxy — keeps backend traffic from hitting the SPA fallback
location /api/ {
proxy_pass http://backend:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Cache static assets aggressively
location ~* \.(js|css|png|jpg|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}
Step 3: Fix — Dockerfile with Nginx
# Create a custom nginx.conf in your project
# nginx/nginx.conf
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend-service:3000/;
}
}
# Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.25-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Step 4: Fix — Kubernetes Ingress with NGINX Ingress Controller
When your SPA runs as a Kubernetes workload fronted by an Ingress, the try_files fix happens at the Ingress annotation level. See also Fix Kubernetes Ingress Not Working if the Ingress itself isn't routing correctly.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: spa-ingress
annotations:
kubernetes.io/ingress.class: nginx
# This annotation makes the NGINX Ingress Controller apply try_files
nginx.ingress.kubernetes.io/configuration-snippet: |
try_files $uri $uri/ /index.html;
spec:
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: spa-service
port:
number: 80
Step 5: Fix — Nginx ConfigMap in Kubernetes
If your pod runs its own Nginx process (e.g. a nginx:alpine container with static files), you can pass the config via a ConfigMap:
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
data:
default.conf: |
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: spa-deployment
spec:
template:
spec:
containers:
- name: spa
image: nginx:1.25-alpine
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: default.conf
- name: spa-files
mountPath: /usr/share/nginx/html
volumes:
- name: nginx-config
configMap:
name: nginx-config
Step 6: Validate the Nginx config before reloading
# Test config syntax before applying
nginx -t
# If running in Docker/Kubernetes, exec into the container first:
kubectl exec -it <nginx-pod> -- nginx -t
# Reload Nginx without downtime (sends SIGHUP)
nginx -s reload
# Or in Kubernetes — rolling restart triggers new pods with updated ConfigMap:
kubectl rollout restart deployment/spa-deployment
5. Verification Steps
# Test all navigation patterns after the fix:
curl -I http://localhost/ # 200 — root
curl -I http://localhost/dashboard # 200 — deep route (was 404)
curl -I http://localhost/users/42 # 200 — deeper route
curl -I http://localhost/nonexistent # 200 — SPA handles this (expected)
# Verify static assets still return real 200s (not index.html)
curl -I http://localhost/static/main.js # 200 with Content-Type: application/javascript
curl -I http://localhost/nonexistent.js # 404 — good, not silently serving index.html
# Check response body for a deep route — should be index.html content
curl -s http://localhost/dashboard | head -5
# Expected: <!DOCTYPE html> (your index.html)
6. Common Mistakes
- Applying
try_files $uri /index.htmlto static asset locations — a missing.jsfile silently returns index.html with 200 instead of 404 - Forgetting to test with
nginx -tbefore reloading — a syntax error in the config silently fails to apply - Having the correct
try_filesin a Dockerfile but the Kubernetes deployment mounts a ConfigMap that overrides it - Setting
rootin thelocationblock instead of theserverblock — can cause unexpected path resolution - Deploying at a subpath (
/app/) without setting the<base href="/app/">tag inindex.htmland without configuring Nginx'ssub_filterfor the path prefix
7. Prevention Tips
- Include a smoke test in CI that GETs a deep route and checks for 200 — catches this before production
- Use
nginx -tas a step in your Docker build to catch config errors at build time rather than at runtime - Keep the SPA Nginx config in your source repo under
nginx/, not hand-edited on servers - If fronted by a load balancer, verify the load balancer is not stripping or rewriting paths unexpectedly
- For Kubernetes deployments, combine with Ingress configuration best practices to handle both SPA routing and TLS termination in one place
8. FAQ
My root path works but any refresh of a sub-route returns 404. Is this definitely Nginx?
Almost certainly yes if you're using Nginx. The symptom — root works, sub-routes fail on refresh, internal navigation works — is the classic SPA routing configuration problem. Confirm with curl -I http://yourhost/sub-route from the same host to see if Nginx is serving the 404.
I added try_files but now my API calls return the index.html page. Why?
Your location /api/ block is probably missing or in the wrong order. Nginx evaluates location blocks by specificity — a location /api/ block should be defined separately and should proxy to your backend, not fall through to the try_files directive. Check that your /api/ location comes before the root location / in your config.
Does this fix work for Vue Router in history mode?
Yes. Vue Router, React Router, and Angular Router all use the HTML5 History API and all have the same Nginx requirement: try_files $uri $uri/ /index.html in the root location block. The Vue Router documentation calls this out explicitly as the required Nginx configuration for history mode.
What about serving the SPA from a subdirectory like /app/?
Subdirectory deployments require two additional steps: (1) set <base href="/app/"> in your index.html (or configure your build tool's base option), and (2) scope the try_files to that location: location /app/ { try_files $uri $uri/ /app/index.html; }. The fallback path must match the subpath prefix.
9. Summary
Nginx 404s on SPA route refresh are always caused by Nginx looking for a physical file at the URL path and finding nothing. The fix in every environment comes down to adding try_files $uri $uri/ /index.html to the root location block — whether that's in a standalone Nginx config, a Dockerfile, or a Kubernetes ConfigMap or Ingress annotation.
| Environment | Where to add the fix |
|---|---|
| Standalone Nginx | location / { try_files $uri $uri/ /index.html; } in server block |
| Docker container | Custom nginx.conf copied into /etc/nginx/conf.d/default.conf |
| Kubernetes Ingress | nginx.ingress.kubernetes.io/configuration-snippet annotation |
| Kubernetes pod (self-managed Nginx) | ConfigMap mounted as /etc/nginx/conf.d/default.conf |
Explore More in This Category
Explore more in this category: Networking & Security guides. Browse all DevOps Compass articles or jump to: Kubernetes, AWS, CI/CD, Containers, Monitoring, Networking.