Mert Özen Further With Every Line
DevOps Web Servers and Hosting — #06

Serving Angular with Nginx: SPA Fallback, Cache Headers, Compression and an /api Proxy

Mert Özen Nov 8, 2026 11 min 4 views
Serving Angular with Nginx: SPA Fallback, Cache Headers, Compression and an /api Proxy

Serving Angular with Nginx needs an SPA fallback with try_files, immutable caching for hashed bundles and no-cache for index.html; otherwise after a deploy a browser asking for an old chunk gets HTML with a 200. With real runs: gzip_static, a runtime config.json, a CORS-free setup with an /api proxy on the same origin, serving under a sub-path with base href, and the allowedHosts trap when putting Angular SSR on Node behind Nginx.

Version note: The examples in this post were run with Angular 22.2 (Angular CLI (Command Line Interface) 22.2.6, Node.js 24.22), Nginx 1.30.6, .NET 10 (ASP.NET Core runtime 10.0.14) and curl 8.23 on Docker 29.

Guess First

Bookstore's Angular app (bookstore-web) is served by Nginx. A user opened the page in the morning and left the tab open. At noon a new version was deployed; the build produced the book-list component's chunk as chunk-MU5TWFYZ.js instead of chunk-JR35WFDJ.js, and the old file was deleted from the server. In the afternoon the user clicks the "Books" link; the old main bundle in the browser asks for the old chunk.

If the Nginx config has only try_files $uri $uri/ /index.html;, which status code and which Content-Type does the server return for this request? What does the user see?

Build Output and SPA Fallback

The folder ng build produces holds one HTML (HyperText Markup Language) file and JavaScript files with content hashes:

Initial chunk files | Names         |  Raw size | Estimated transfer size
chunk-E6I2YBCN.js   | -             | 119.52 kB |                36.02 kB
main-VVATF72S.js    | main          |  94.45 kB |                24.03 kB
chunk-KLUEAQAT.js   | -             |  20.67 kB |                 6.06 kB
styles-5INURTSO.css | styles        |   0 bytes |                 0 bytes

Lazy chunk files    | Names         |  Raw size | Estimated transfer size
chunk-JR35WFDJ.js   | book-list     |   1.21 kB |               659 bytes
chunk-5BF2ERJB.js   | order-detail  | 377 bytes |               377 bytes

The eight characters in the file name are a hash of the content: if the content changes, so does the name. The app is an SPA (Single Page Application); paths such as /books and /orders/42 don't exist on disk, the Angular router resolves them in the browser. The server's job comes down to two rules: if the file exists serve it, otherwise serve index.html. In Nginx that's one line:

location / {
    try_files $uri $uri/ /index.html;
}

try_files tries its arguments in order: first the requested file, then a folder with the same name, and if neither exists an internal redirect to the last argument (/index.html). This is the Nginx side of the SPA routing requirement described in the "Build and Deploy" post of the Angular series. But the answer to the opening question is right here too:

# release-2 deployed, the browser asks for the old chunk
# config with only try_files
HTTP/1.1 200 OK
Content-Type: text/html

# separate location for hashed files with =404
HTTP/1.1 404 Not Found
Content-Type: text/html

With a config that has only try_files, the request for the old chunk is also "a path that doesn't exist" and falls through to index.html: the browser expecting JavaScript gets HTML with a 200. The browser can't load the module, reports a MIME (Multipurpose Internet Mail Extensions) type error, and the link the user clicked does nothing. Since the status is 200, no error shows up in the access log either. A separate location for hashed files that returns 404 instead of the fallback at least makes the problem visible and lets the error handler in the app's router reload the page. The full fix is to keep old chunks on the server for a while: the deploy should add the new build's files next to the old ones instead of deleting them.

Cache Headers: Hashed Bundles and index.html

A hashed file's content never changes; if it changes, its name changes. So the browser can use it for a year without ever asking the server. index.html is the opposite: it says which hashed files to load, and a stale copy leaves the user on the old version. Bookstore's config:

upstream bookstore_api {
    server 172.30.85.21:8080;
}

server {
    listen 80;
    server_name bookstore.local;
    root /srv/bookstore-web;

    gzip on;
    gzip_static on;
    gzip_vary on;
    gzip_types text/css application/javascript application/json;

    # hashed bundles: cache for a year, never revalidate
    location ~* "-[A-Z0-9]{8}\.(js|css)$" {
        add_header Cache-Control "public, max-age=31536000, immutable";
        try_files $uri =404;
    }

    # entry point and runtime config: always revalidate
    location = /index.html {
        add_header Cache-Control "no-cache";
    }
    location = /config.json {
        add_header Cache-Control "no-cache";
    }

    # same-origin API: no CORS needed
    location /api/ {
        proxy_pass http://bookstore_api;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # SPA fallback: unknown paths get index.html, the Angular router takes over
    location / {
        try_files $uri $uri/ /index.html;
    }
}

Response headers for four requests:

== /orders/42
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 329
Vary: Accept-Encoding
ETag: "6aeff7d0-149"
Content-Encoding: gzip
Cache-Control: no-cache
== /index.html
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 329
Vary: Accept-Encoding
ETag: "6aeff7d0-149"
Content-Encoding: gzip
Cache-Control: no-cache
== /config.json
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 65
Vary: Accept-Encoding
ETag: "6aeff7d0-41"
Content-Encoding: gzip
Cache-Control: no-cache
== /main-VVATF72S.js
HTTP/1.1 200 OK
Content-Type: application/javascript
Content-Length: 26710
Vary: Accept-Encoding
ETag: "6aeff7d0-6856"
Content-Encoding: gzip
Cache-Control: public, max-age=31536000, immutable

It matters that /orders/42 also gets no-cache: try_files's internal redirect moved the request to location = /index.html and the header was added there. no-cache doesn't mean "don't cache", it means "ask the server before using it"; the browser asks with the ETag, gets a 304 if the file hasn't changed, and spends only a request of a few hundred bytes. immutable tells the browser "don't ask even when the page is reloaded".

The regex location is also a reminder of a warning from earlier posts: once add_header is defined in a location, the add_header directives of the outer context don't apply in that location. When you add a server-wide security header, it disappears in these locations; we'll come back to this with add_header_inherit in the seventh post.

Compression: gzip, gzip_static and Brotli

gzip on compresses the file on every request and spends CPU (Central Processing Unit). Since the build output doesn't change, it makes more sense to compress once, at deploy time. gzip_static on sends a .gz copy directly if one exists next to the requested file:

$ for f in *.js *.css *.json index.html; do gzip -k -9 "$f"; done
$ ls -l | awk '{print $5, $9}'
377 chunk-5BF2ERJB.js
292 chunk-5BF2ERJB.js.gz
119518 chunk-E6I2YBCN.js
40092 chunk-E6I2YBCN.js.gz
1213 chunk-JR35WFDJ.js
721 chunk-JR35WFDJ.js.gz
20666 chunk-KLUEAQAT.js
6732 chunk-KLUEAQAT.js.gz
54 config.json
65 config.json.gz
15086 favicon.ico
528 index.html
329 index.html.gz
94452 main-VVATF72S.js
26710 main-VVATF72S.js.gz
0 styles-5INURTSO.css
20 styles-5INURTSO.css.gz

In the response above, main-VVATF72S.js came with Content-Length: 26710; that's exactly the size of the .gz file, so Nginx didn't compress anything, it sent the ready-made file. With dynamic gzip the size isn't known in advance, so the response goes out with Transfer-Encoding: chunked. The list holds another lesson: the 54-byte config.json became 65 bytes when compressed, and gzip_static sent that larger file. Don't precompress files under a few hundred bytes; with dynamic gzip gzip_min_length sets that threshold.

gzip_vary on adds Vary: Accept-Encoding to the response so that a CDN (Content Delivery Network) or proxy cache in between doesn't hand the compressed copy to a client without gzip support. Brotli compresses better than gzip but isn't in Nginx itself: the official image's module folder contains ngx_http_acme_module, njs, geoip, image_filter and xslt, no brotli. For brotli you need to build the third-party ngx_brotli module or use a distribution that packages it; in most setups leaving brotli to the CDN in front is simpler.

Runtime config.json

Angular's environment.ts files are baked into the code at build time; that means a separate build for staging and production. If you want to carry the same build to every environment, you read the environment information from a JSON (JavaScript Object Notation) file when the app starts:

import { Injectable, signal } from '@angular/core';

export interface AppConfig {
  apiBaseUrl: string;
  environment: string;
}

@Injectable({ providedIn: 'root' })
export class AppConfigService {
  readonly config = signal<AppConfig>({ apiBaseUrl: 'api', environment: 'unknown' });

  async load(): Promise<void> {
    const response = await fetch('config.json', { cache: 'no-store' });
    this.config.set(await response.json());
  }
}
export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideHttpClient(),
    provideRouter(routes, withComponentInputBinding()),
    provideAppInitializer(() => inject(AppConfigService).load()),
  ]
};

provideAppInitializer waits for the promise to finish before the app starts. The file lives in the build as public/config.json; the deploy overwrites it with the environment's file or mounts that environment's file in Nginx. This file must get no-cache too, or an environment change never reaches the browser. Don't put secrets in it: everything that goes to the browser is public.

/api on the Same Origin: a Setup without CORS

If Angular is served from https://bookstore.example and the API (Application Programming Interface) from https://api.bookstore.example, these are two separate origins for the browser and every request is subject to CORS (Cross-Origin Resource Sharing) rules: defining a policy on the API, extra settings for credentialed requests, and a preflight OPTIONS request before every POST. The location /api/ in the config above gathers both onto the same origin; the browser takes /api/books for a part of its own page:

$ curl -s -H "Host: bookstore.local" http://172.30.85.10/api/books | head -c 120
[{"id":1,"title":"Dune","author":"Frank Herbert","price":12.50},{"id":2,"title":"Foundation","author":"Isaac Asimov","pr

No preflight, no CORS policy, cookies go along naturally. URLs (Uniform Resource Locators) in the Angular code stay relative, like 'api/books'; Nginx, not config, handles the environment difference. For cases that need a separate origin (when other apps also use the API), CORS itself is covered in the "CORS and Security" post of the ASP.NET Core Web API series.

Serving under a Sub-Path: base href

Sometimes the app isn't served at the root of the domain but under /shop/. You tell Angular at build time:

$ npx ng build --base-href /shop/ --output-path dist-shop
$ grep -o '<base[^>]*>\|src="[^"]*"' dist-shop/browser/index.html
<base href="/shop/">
src="main-TAGGIZ6J.js"

File paths stay relative; the browser resolves them against <base href>. On the Nginx side the fallback has to point at the sub-path too:

server {
    listen 80;
    server_name bookstore.local;
    root /srv/sites;

    location /shop/ {
        try_files $uri $uri/ /shop/index.html;
    }
}
$ curl -s -H "Host: bookstore.local" http://172.30.85.10/shop/orders/42 | grep -o "<base[^>]*>"
<base href="/shop/">
$ curl -s -o /dev/null -w "/shop/main-TAGGIZ6J.js -> %{http_code} %{content_type}\n" -H "Host: bookstore.local" http://172.30.85.10/shop/main-TAGGIZ6J.js
/shop/main-TAGGIZ6J.js -> 200 application/javascript

I put the build output in /srv/sites/shop and used root; when the URL and the disk have the same structure you never run into the alias traps from the fourth post. One detail: the relative 'api/books' and 'config.json' now go to /shop/api/books and /shop/config.json as well. Either define the API proxy as /shop/api/ or give the API address from the root (/api/books) in Angular.

Angular SSR: Putting the Node Server behind Nginx

With SSR (Server-Side Rendering) the page is rendered on the server on the first request; search engines and slow devices get full HTML. In this case Nginx no longer just serves files, it proxies to a Node.js process. I took an app generated with ng new --ssr, set its route to RenderMode.Server, built it and ran it with node server/server.mjs on port 4000. The Nginx config is the same as the API proxy:

upstream bookstore_ssr {
    server 172.30.85.40:4000;
}

server {
    listen 80;
    server_name bookstore.local;

    location / {
        proxy_pass http://bookstore_ssr;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

The first request returned 400. Node's log:

Received "x-forwarded-for" header but "trustProxyHeaders" was not set up to allow it.
For more information, see https://angular.dev/best-practices/security#configuring-trusted-proxy-headers
Received "x-forwarded-proto" header but "trustProxyHeaders" was not set up to allow it.
For more information, see https://angular.dev/best-practices/security#configuring-trusted-proxy-headers
ERROR: Bad Request ("http://bookstore.local/").
Header "host" with value "bookstore.local" is not allowed.

Angular's SSR engine renders only for allowed hosts, as protection against SSRF (Server-Side Request Forgery) attacks, and ignores X-Forwarded-* headers by default. It's the same logic as ASP.NET Core's forwarded headers middleware; the trust rules we saw in the third post apply here too. The allow list is given with security.allowedHosts in angular.json or with environment variables:

$ docker run -d --name webhwork-ssr ... -e PORT=4000 \
    -e NG_ALLOWED_HOSTS=bookstore.local \
    -e NG_TRUST_PROXY_HEADERS=x-forwarded-proto,x-forwarded-for \
    node:24-alpine node server/server.mjs
$ curl -s -H "Host: bookstore.local" http://172.30.85.10/ | grep -o "<h1[^>]*>[^<]*</h1>\|<app-root[^>]*>" | head -3
<app-root ng-version="22.2.5" _nghost-ng-c1687365377="" ngh="0" ng-server-context="ssr">
<h1 _ngcontent-ng-c1687365377="">Hello, bookstore-ssr</h1>

ng-server-context="ssr" shows the page was rendered on the server. In production systemd keeps this Node process running, just like Kestrel; the unit file has the same shape as the Kestrel unit in the eighth post (ExecStart=/usr/bin/node /var/www/bookstore-ssr/server/server.mjs, a separate user, Restart=always). In this experiment I ran Node in a container, not under systemd. Instead of asking Node for static files you can also serve the browser folder from Nginx; adding the location for hashed files above to the SSR config is enough.

QuestionStatic (CSR)SSR
Runs on the serverOnly NginxNginx and a Node.js process
First meaningful contentOnce the JavaScript is downloaded and runIn the first HTML response
SEO (Search Engine Optimization) and link previewsWeakStrong
Scale and costServing files, can move to a CDNEvery request costs CPU, the process must be monitored
ForAdmin screens behind a login, internal appsPublic catalogue, book detail pages

A sensible split for Bookstore: the public catalogue as SSR, the order and admin screens as static. In Angular you can make this split per route with RenderMode; on the Nginx side a single upstream is enough.

In Production

Order the deploy according to the cache strategy: new hashed files first, index.html last. The other way round, a user who gets the new index.html asks for files that haven't been copied yet and gets 404s. Leave the old build's files on the server for at least one release; open tabs will ask for them.

Write a post-deploy check on the Nginx side that tests the cache headers: no-cache on index.html, immutable on a hashed file, 200 and text/html on a random deep link, 404 on a non-existent .js. Four curls catch a wrong regex before it reaches production.

If there's a CDN in front, the same headers drive it too. If index.html is cached at the CDN, the deploy reaches users only when the CDN's copy expires; so either rely on no-cache or send a purge request to the CDN at deploy time.

Common Pitfalls

Caching index.html for a long time. After a deploy users stay on the old version, and if old chunks were deleted the app half works. The symptom: complaints to support saying "it's fixed for me, still broken for the customer".

Applying the SPA fallback to hashed files too. A missing chunk returns HTML with a 200 instead of a 404, and the error shows up in no log.

Precompressing small files. As in the config.json example, the .gz can be bigger than the original and gzip_static sends it without question.

Putting secrets in config.json or caching it. A file that goes to the browser isn't secret; a cached file swallows environment changes.

Putting SSR behind Nginx and forgetting allowedHosts. The result is a 400 and an SSRF warning in Node's log; since everything works on localhost, the problem appears on the first deploy.

A Small Experiment

★☆☆ In Bookstore's config, delete the try_files $uri =404; line in the regex location and repeat the request for the old chunk. What status code came back and why? Explain how Nginx finds the file from root in a regex location.

★★☆ Write a deploy script: it copies the new build to a releases/<date> folder, copies the previous release's hashed files into the new folder too, generates the .gz files and flips the current symlink last. Verify that a tab opened in the morning can click the "Books" link after the deploy.

★★★ In the SSR app, serve hashed files from Nginx and pages from Node: mount the browser folder into Nginx, add the regex location for hashed files and send the rest to bookstore_ssr. Which requests now show up in Node's access log?

Both halves of Bookstore are behind Nginx, but still over plain HTTP (Hypertext Transfer Protocol). In the next post we set up TLS (Transport Layer Security) termination in Nginx, HTTP/2 and HTTP/3, HSTS (HTTP Strict Transport Security) and the other security headers, rate limiting, and load balancing across several Kestrel instances.