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

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

7. Prevention Tips

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.

EnvironmentWhere to add the fix
Standalone Nginxlocation / { try_files $uri $uri/ /index.html; } in server block
Docker containerCustom nginx.conf copied into /etc/nginx/conf.d/default.conf
Kubernetes Ingressnginx.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.