Nginx

Nginx Subfolder Reverse Proxy Returning 404 for Relative Static Assets

When an application works fine at the domain root but returns 404s for its own static assets once reverse-proxied under a subfolder, the application itself is generating asset URLs relative to the root (/) rather than accounting for the subfolder prefix — this is an application configuration issue far more often than an Nginx misconfiguration.

The Problem

An application is proxied under a subfolder path rather than the domain root:

location /app/ {
    proxy_pass http://backend/;
}

The main page loads, but its CSS, JavaScript, and image references all 404:

GET /static/css/main.css HTTP/1.1" 404
GET /static/js/bundle.js HTTP/1.1" 404

Notice the requested paths are missing the /app/ prefix entirely — the browser is requesting /static/css/main.css directly at the domain root, not /app/static/css/main.css.

Absolute vs relative asset paths <link href="/static/main.css"> resolves from domain root <link href="static/main.css"> resolves relative to current path

Why It Happens

When HTML includes an asset reference starting with a leading slash (like /static/main.css), browsers treat that as an absolute path relative to the domain root, completely ignoring whatever subfolder the current page was actually loaded from. Since the application itself was likely built and tested assuming it lives at the domain root, it generates all its asset references this way by default — and unless it's explicitly told about the subfolder it's actually being served under, it has no way to know to prefix those paths correctly. This produces requests that go to the domain root instead of the subfolder Nginx expects, and Nginx correctly returns 404 since it has no route configured for that root-level path.

The Fix

The most correct, durable fix is configuring the application itself to be aware of the subfolder it's being served under, so it generates correctly prefixed asset URLs from the start — most frameworks have a specific configuration option for exactly this ("base path," "public path," "base URL," depending on the framework):

# Example: many frameworks support an environment variable or config option
PUBLIC_URL=/app
BASE_PATH=/app

Check your specific framework's documentation for the exact mechanism, since this varies significantly — React apps often use PUBLIC_URL, many server-side frameworks have a dedicated "mount path" or "base path" setting, and static site generators typically have their own equivalent "base URL" configuration.

If modifying the application's own configuration isn't practical, Nginx's sub_filter directive can rewrite absolute asset paths in the HTML response on the fly, injecting the subfolder prefix before the response reaches the browser:

location /app/ {
    proxy_pass http://backend/;
    sub_filter 'href="/static' 'href="/app/static';
    sub_filter 'src="/static' 'src="/app/static';
    sub_filter_once off;
}

This is more fragile than fixing the application's own configuration, since it requires anticipating every specific pattern that needs rewriting, and can silently miss cases (dynamically generated URLs in JavaScript, for example, which sub_filter's simple string replacement won't reach) — treat it as a workaround for cases where the application-level fix genuinely isn't accessible, not a first-choice solution.

An alternative architectural approach, if practical for your setup, is serving the application at the domain root of its own subdomain instead of a subfolder of a shared domain (app.example.com instead of example.com/app/) — this sidesteps the entire subfolder-relative-path problem, since the application genuinely is at the root from its own perspective, requiring no special subfolder awareness or Nginx rewriting at all.

Still Not Working?

If you've configured the application's base path setting but some assets still 404, check whether those specific assets are being requested by client-side JavaScript constructing URLs dynamically (rather than static HTML <link>/<script> tags), since dynamically constructed URLs in JavaScript code need the application's routing/API-client logic to also be aware of the base path — a base path setting that correctly affects server-rendered HTML doesn't automatically also affect every client-side fetch() call or dynamically generated asset URL unless the application's JavaScript code is also explicitly using that same base path configuration value consistently throughout.