← All posts

Blazor WebAssembly hosting on a VPS: nginx config that actually works

A Blazor WebAssembly build is a folder of static files — index.html, a _framework directory full of .dll and .wasm files, and whatever’s in wwwroot. Serving static files should be the easy part. In practice, four separate nginx details each cause a different failure mode if you miss them, and every one below was verified against a real dotnet publish output on Ubuntu 24.04, not assumed from the docs.

What a Blazor WASM publish actually produces

dotnet publish -c Release -o publish

Look inside publish/wwwroot/_framework and you’ll find both the raw files and pre-compressed .br and .gz variants sitting next to them —dotnet.native.<hash>.wasm, dotnet.native.<hash>.wasm.br, dotnet.native.<hash>.wasm.gz, and the same for every assembly .wasm file. The build already did the expensive compression work at publish time; the nginx config’s job is to serve the pre-built .br/.gz file instead of compressing on every request.

The server block

server {
    listen 80;
    server_name app.example.com;
    root /var/www/app/current;
    index index.html;

    gzip_static on;
    brotli_static on;

    location ~ \.wasm$ {
        types { }
        default_type application/wasm;
    }

    location /_framework/ {
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

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

brotli_static needs a module Ubuntu’s stock nginx package doesn’t ship by default:

apt install -y libnginx-mod-http-brotli-static

It loads itself via a file nginx already includes (/etc/nginx/modules-enabled/), so a reload after installing is all that’s needed — no config change beyond brotli_static on;. gzip_static is built into stock nginx and needs no extra package.

Why each line is there

types { } default_type application/wasm; for .wasm files. Older nginx builds don’t ship .wasm in their default MIME type table, so without this override .wasm files are served as application/octet-stream. Some browsers still run the app anyway; strict CSP or CORS setups, and some CDN/proxy layers in front of nginx, reject or mishandle a WASM module served with the wrong content type. Confirmed by request: a raw curl -I against a _framework/*.wasm file should show Content-Type: application/wasm.

gzip_static on; brotli_static on; tell nginx to look for a pre-compressed .gz or .br file next to the one requested and serve that instead of the original — and, critically, instead of nginx compressing the file itself on every request with the general-purpose gzip on; directive. Verified with curl -H "Accept-Encoding: br" against a _framework/*.wasm file: the response comes back with Content-Encoding: br and the size of the pre-built .br file, not the uncompressed original.

location /_framework/ { add_header Cache-Control ... }. Every file in _framework has a content hash in its filename (dotnet.native.kiufslnrq2.wasm) — if the file’s contents ever change, its name changes too. That makes it safe to cache these files for a year: a browser that already has dotnet.native.kiufslnrq2.wasm never needs to ask again, and a new release ships under new hashed filenames instead of overwriting the old ones. Without this header, a return visitor re-downloads the entire runtime and every assembly on every visit.

try_files $uri $uri/ /index.html;. Blazor WASM does its own client-side routing in the browser — there’s no /dashboard/settings file on disk, only index.html, which loads the Blazor router that then reads the URL and shows the right component. Without this fallback, a user who refreshes the page on /dashboard/settings (rather than navigating to it from /) gets nginx’s plain 404, because nginx is looking for a file that doesn’t exist. try_files checks for a matching file, then a matching directory, and only falls back to index.html if neither exists — so real static assets (css/app.css, icon-192.png) still serve directly.

Standalone vs. hosted WASM

A standalone Blazor WASM app is exactly the static-file case above: nothing but wwwroot’s contents served by nginx, no server-side code at all. A hosted Blazor WASM app pairs the same static files with an ASP.NET Core API in the same solution, usually serving the client’s wwwroot from the API project itself (app.UseBlazorFrameworkFiles() and app.MapFallbackToFile("index.html") in Program.cs) rather than from nginx directly. If you’re running hosted, nginx’s job changes: it reverse-proxies to Kestrel (the same proxy_pass block as any ASP.NET Core app, from the Hetzner walkthrough) and it’s the API project’s own middleware — not nginx’s try_files — that serves the static WASM files and falls back to index.html for client routes. Don’t combine both: a hosted app with an nginx static-file block in front of it is two different systems trying to serve the same routes, and whichever runs second silently loses.

A note on Content-Security-Policy

If you add a CSP header (Content-Security-Policy via add_header in the server block, or set by the app), WebAssembly’s .wasm files need script-src 'wasm-unsafe-eval' — the older 'unsafe-eval' was historically required for WASM compilation in some browsers but 'wasm-unsafe-eval' is the correct, narrower directive on current browsers. A CSP that only allows 'self' for script-src with no WASM allowance will fail silently in the browser console with a compilation error, not a network error, which is easy to mistake for a server misconfiguration when it’s actually a client-side policy blocking the runtime from starting at all.

Troubleshooting from the browser console

“Failed to load resource: the server responded with a status of 404” for a _framework file. Almost always root pointing at the wrong directory — it must be the folder containing index.html and _framework directly (publish/wwwroot, not publish itself, and not current if current points one level too high).

“Response has unsupported MIME type” for a .wasm file, in a browser that enforces strict MIME checking. The location ~ \.wasm$ { types {} default_type application/wasm; } block above is missing, disabled, or shadowed by a more specific location block that matched first — nginx uses the most specific matching location, so a broader regex location added later in the file can silently take precedence.

A page works on first load but 404s on refresh. The try_files fallback is missing, disabled, or the site is still serving from sites-available without a matching symlink in sites-enablednginx -T (capital T) prints every config nginx actually loaded, which catches “I edited the file but nginx never picked it up” immediately.

Blazor Server is a different problem

Everything above is specific to WebAssembly’s all-static-files model. Blazor Server keeps a persistent connection open per user instead of shipping the app to the browser, and that connection is a WebSocket — nginx needs proxy_set_header Upgrade $http_upgrade; and proxy_set_header Connection "upgrade"; in its proxy_pass block (the same reverse-proxy shape as any ASP.NET Core app, not a static site config at all), or the connection silently can’t upgrade and the app appears to hang. See the Hetzner walkthrough for the standard ASP.NET Core reverse-proxy block; Blazor Server uses that, not this one. Which model you’re running is a build-time choice — see supported runtimes for what DotDeployer detects automatically.

Confirming it’s actually working

Three checks, in order, on the live site:

curl -sI https://app.example.com/_framework/dotnet.native.<hash>.wasm
curl -sI -H "Accept-Encoding: br" https://app.example.com/_framework/dotnet.native.<hash>.wasm
curl -sI https://app.example.com/some/deep/client/route

The first should show Content-Type: application/wasm. The second should show Content-Encoding: br and a smaller Content-Length than the first. The third should return 200, not 404 — that’s try_files doing its job on a URL nginx has never heard of.

Deploy it with DotDeployer

DotDeployer detects a Blazor WebAssembly project automatically and configures nginx with exactly this MIME type, compression, caching and routing setup -- no server block to write by hand.

Deploy to Hetzner with DotDeployer · Supported runtimes