September 12, 2026
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-enabled — nginx -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.