Are you sure your Blazor app is really ready after dotnet publish
? Most deployment failures I’ve helped debug were not code bugs – they were tiny publish and hosting missteps that surface only in production. Let’s fix that.
I’ll walk you through a pragmatic, field-tested path for publishing Blazor WebAssembly and Blazor Server apps quickly and safely. You’ll get copy‑pasteable snippets, gotchas to avoid, CI/CD examples, and a deployment checklist you can run before every release.
What “Publish” Actually Ships in Blazor
Blazor has two primary hosting models, and the output you ship is very different for each:
Model | What you deploy | Typical hosting | Key pitfalls |
---|---|---|---|
Blazor WebAssembly (WASM) | Static files (HTML, CSS, JS, .wasm , assets) | Any static host (Nginx, IIS static, Apache, S3/CloudFront, Azure Static Web Apps, GitHub Pages) | Client‑routing 404s, base href mismatch, stale service worker cache |
Blazor Server | ASP.NET Core app (.dll, runtime if self‑contained), static assets | Kestrel behind reverse proxy (Nginx/Apache/IIS, Azure App Service, containers) | WebSockets off, sticky sessions, Data Protection keys, connection drops |
Think of WASM as ship-and-serve static; think of Server as host a long‑lived SignalR app.
Golden Rules for dotnet publish
Use Release, target your runtime, and keep the output deterministic.
# WebAssembly (AOT optional)
dotnet publish -c Release \
-p:RunAOTCompilation=false \
-o ./publish
# WebAssembly with AOT (smaller CPU time, larger payload)
dotnet publish -c Release \
-p:RunAOTCompilation=true \
-o ./publish
# Blazor Server (framework‑dependent)
dotnet publish -c Release -o ./publish
# Blazor Server (self‑contained Linux x64)
dotnet publish -c Release -r linux-x64 --self-contained true -o ./publish
Recommended project properties you can keep in your .csproj
(adjust per app):
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<!-- WASM size/perf tradeoffs -->
<RunAOTCompilation>false</RunAOTCompilation> <!-- set true for perf-critical apps -->
<InvariantGlobalization>true</InvariantGlobalization> <!-- if you don't need full ICU -->
<BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
<!-- Server publish ergonomics -->
<PublishSingleFile>true</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed> <!-- enable only after trim audits! -->
</PropertyGroup>
Why these matter
- AOT: Faster runtime, larger download. Enable after you’ve budgeted payload.
- Invariant globalization: Shrinks WASM apps dramatically if your app doesn’t require locale‑specific formatting.
- Trimming on Server: Only after you annotate/refactor reflection‑heavy bits. Otherwise you’ll trim away services you need.
Tip: Keep a tiny
publish.ps1
with your flags so everyone on the team publishes the same way.
Blazor WebAssembly: Production‑Ready Static Hosting
Client‑side routing: stop the 404s
SPA routes like /orders/42
must fall back to index.html
.
Nginx
server {
listen 80;
server_name example.com;
root /usr/share/nginx/html; # your published folder
location / {
try_files $uri $uri/ /index.html;
}
}
IIS (web.config)
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<rewrite>
<rules>
<rule name="SPA Fallback" stopProcessing="true">
<match url=".*"/>
<conditions>
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true"/>
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true"/>
</conditions>
<action type="Rewrite" url="/index.html"/>
</rule>
</rules>
</rewrite>
<staticContent>
<remove fileExtension=".webp" />
<mimeMap fileExtension=".webp" mimeType="image/webp" />
</staticContent>
</system.webServer>
</configuration>
base href
must match your mount path
If your app lives under /app/
, set it in wwwroot/index.html
:
<base href="/app/" />
Deploying to the site root? Keep /
.
Symptom of mismatch: white screen, 404s for
_framework/blazor.webassembly.js
or your assets.
Compression & caching
WASM builds produce precompressed assets. Configure your server to serve .br
and .gz
when the Accept-Encoding
header allows it, and to cache static assets long‑term with fingerprinting.
Nginx (static, long cache, Brotli/gzip)
http {
gzip on; gzip_static on; # serve .gz if present
brotli on; brotli_static on; # serve .br if present
include mime.types;
default_type application/octet-stream;
}
server {
location / { try_files $uri $uri/ /index.html; }
location ~* \.(?:css|js|wasm|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html { add_header Cache-Control "no-store"; }
}
Service worker: update strategy that won’t annoy users
If you enable PWA, users may see stale UI until a reload. Prompt gently when a new version is available:
// wwwroot/service-worker-updates.ts
export function wireUpdateToast(reg: ServiceWorkerRegistration) {
if (!reg || !reg.waiting) return;
const channel = new BroadcastChannel('sw-updates');
channel.onmessage = (e) => {
if (e.data === 'SW_UPDATE_READY') {
// show a toast in your UI and on confirm:
reg.waiting?.postMessage({ type: 'SKIP_WAITING' });
window.location.reload();
}
};
}
In your service worker, broadcast when a new version is installed and waiting. Users get a prompt instead of ghost updates.
Environment‑specific config (WASM)
There’s no server to inject env vars. Use a JSON fetched at startup:
// wwwroot/config.production.json
{
"ApiBaseUrl": "https://api.example.com",
"AppVersion": "1.2.3"
}
// Program.cs (WASM)
var builder = WebAssemblyHostBuilder.CreateDefault(args);
using var http = new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) };
var cfgJson = await http.GetStringAsync("config.production.json");
var cfg = JsonSerializer.Deserialize<AppConfig>(cfgJson)!;
builder.Services.AddSingleton(cfg);
await builder.Build().RunAsync();
Publish the right file per environment (e.g., rename on CI or pick by ASPNETCORE_ENVIRONMENT
).
Blazor Server: Production‑Ready Hosting
Blazor Server depends on SignalR (WebSockets preferred). Deploy like any ASP.NET Core app, then confirm real‑time connectivity.
Reverse proxy with WebSockets
Nginx
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:5000; # Kestrel
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 120s;
}
}
IIS: Ensure WebSocket Protocol feature is installed and enabled on the site/app.
Scaling: sticky sessions or Azure SignalR
Each user maintains a circuit. In a multi‑instance setup either:
- enable sticky sessions on your load balancer, or
- use Azure SignalR Service for scale‑out (offloads connection management).
Data Protection keys
Multiple instances must share Data Protection keys (cookies, auth). Persist them to a common location (file share, Redis, or KeyVault). Example in Program.cs
:
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/var/keys"))
.SetApplicationName("MyBlazorServerApp");
Health checks & graceful shutdown
builder.Services.AddHealthChecks();
app.MapHealthChecks("/health");
Configure your orchestrator (Kubernetes/App Service) to probe /health
and allow graceful shutdown so circuits disconnect cleanly.
Publish profiles
Useful MSBuild properties for Server:
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PublishAot>false</PublishAot> <!-- for native AOT console/worker, not Blazor Server -->
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
</PropertyGroup>
Containers: Repeatable, Fast Deployments
Dockerfile – Blazor WASM (static via Nginx)
# build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -p:RunAOTCompilation=false -o /app/publish
# serve
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/publish/wwwroot /usr/share/nginx/html
nginx.conf
contains the SPA fallback and static caching (as above).
Dockerfile – Blazor Server
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -r linux-x64 --self-contained true -o /out
FROM base AS final
WORKDIR /app
COPY --from=build /out .
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["./YourServerApp"]
Run behind a load balancer with WebSockets enabled.
CI/CD: GitHub Actions Examples
WASM → GitHub Pages (or any static host)
name: deploy-wasm
on:
push:
branches: [ main ]
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '8.0.x' }
- run: dotnet publish -c Release -o out
- name: add base href for subfolder
run: sed -i 's|<base href="/" />|<base href="/myapp/" />|' out/wwwroot/index.html
- name: deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: out/wwwroot
destination_dir: myapp
Server → Azure Web App (zip deploy)
name: deploy-server
on:
push:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '8.0.x' }
- run: dotnet publish -c Release -o publish
- run: zip -r app.zip publish/
- uses: azure/webapps-deploy@v2
with:
app-name: my-blazor-server-prod
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE }}
package: app.zip
Build stamping (nice for support)
Add a tiny file during CI and render it in your footer.
echo "${GITHUB_SHA::7} • $(date -u +%Y-%m-%dT%H:%M:%SZ)" > out/wwwroot/version.txt
@code {
private string? Version;
protected override async Task OnInitializedAsync() {
try { Version = await Http.GetStringAsync("version.txt"); }
catch { Version = "dev"; }
}
}
<footer>Build: @Version</footer>
Performance & Size: Practical Toggles
- AOT (WASM): Enable when CPU‑heavy; measure FCP vs payload. Start with a couple of hot components before going all‑in.
- Linker warnings: Treat as errors during CI and keep a
ILLink.Descriptors.xml
for libraries that use reflection (e.g., JSON serializers, DI scanning). - Images: Prefer
.webp
/.avif
. Add properContent-Type
and long cache. - ICU: If you need only a few locales, ship sharded ICU rather than full.
- HTTP/2: Ensure your edge supports it; WASM benefits from multiplexing.
ILLink.Descriptors.xml
example:
<linker>
<assembly fullname="MyLibUsingReflection">
<type fullname="MyLib.TypeFactory" preserve="all" />
</assembly>
</linker>
Security Headers You Should Actually Set
Static (WASM):
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "geolocation=(), camera=()" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# CSP: adjust for your CDNs/endpoints
add_header Content-Security-Policy "default-src 'self'; connect-src 'self' https://api.example.com; img-src 'self' data:; style-src 'self' 'unsafe-inline';" always;
Server: add the same via app.Use(async (ctx, next) => { ... })
or a middleware package. Always force HTTPS.
Observability That Pays Off in 5 Minutes
- App Insights/Sentry: capture errors + performance.
- Server logs: include connection id / circuit id in logs for Blazor Server.
- Frontend events: log version, user agent, and fatal WASM exceptions to your backend.
Example minimal client error hook (WASM):
window.addEventListener('error', e => {
navigator.sendBeacon('/client-errors', JSON.stringify({
message: e.message, stack: e.error?.stack, href: location.href, version: window.AppVersion
}));
});
Troubleshooting: Symptoms → Fix
- Deep-linking 404s → Configure SPA fallback to
index.html
. - White screen after deploy → Wrong
<base href>
; check network panel for_framework/*
404. - App doesn’t update → PWA cache old; implement update prompt or bump file names.
- Huge download → Turn on Brotli, review AOT, enable invariant globalization, trim images.
- Blazor Server disconnects → WebSockets disabled or idle timeouts too aggressive.
- Auth breaks after scale‑out → Persist Data Protection keys; ensure sticky sessions or Azure SignalR.
- Reflection type not found → Trimmed by linker; add
ILLink.Descriptors.xml
orDynamicDependency
.
Mini Checklists (save these!)
WASM pre‑flight
- Release build, correct
<base href>
- SPA fallback rule configured
- Brotli/gzip enabled; immutable cache for assets
- PWA update prompt (if PWA)
- Config JSON per environment
- Security headers set
Server pre‑flight
- WebSockets enabled end‑to‑end
- Health check mapped; graceful shutdown configured
- Sticky sessions or Azure SignalR
- Data Protection keys persisted
- Observability connected (errors + perf)
FAQ: Blazor Publish & Deployment
No. Measure. AOT boosts CPU‑heavy components, but it increases download size and build time. Start targeted; expand only if your metrics say so.
Under ~3-5 MB compressed is a pleasant baseline for typical line‑of‑business apps. Use Brotli, trim images, consider invariant globalization, and lazy‑load big features.
Yes – unless you front it with Azure SignalR Service. Each user’s circuit must hit the same instance.
Safe after you audit reflection usage. Keep PublishTrimmed=false
until you annotate or remove dynamic access patterns.
You don’t. WASM runs on the client; any secret would be public. Put secrets behind a server API and call that.
Serve a small config.{env}.json
per environment and fetch it at startup; avoid bundling production endpoints in debug builds.
Signal updates via service worker events and prompt a reload. Also consider Cache-Control: no-store
for index.html
.
Start with a Basic/Standard App Service with WebSockets on. Monitor connections and scale out with sticky sessions or Azure SignalR.
Conclusion: Ship Blazor with Confidence (and Speed)
Publishing a Blazor app isn’t hard – it’s precise. A correct base href
, SPA fallback, compression+cache headers, and (for Server) WebSockets + sticky sessions will solve 80% of real‑world issues. The rest is performance tuning, safe trimming, and CI discipline.
Take the checklists above, paste them into your repo, and automate what you can. Next release, measure your TTFB/FCP and error rate – you’ll notice the difference.
Question for you: what’s the nastiest deployment bug you’ve hit with Blazor – and how did you finally catch it? Share below so we can all avoid it next time.
- Blazor Overview: A Complete Guide to .NET Web Development
- Learning Blazor Components & Data Binding (Examples)
- Blazor Publish Guide: Fast, Safe, Production Deployments
- Blazor Layout: Essential Guide for Beginners
- Blazor Routing Guide: Components, Parameters, and Navigation