Blazor Publish Guide: Fast, Safe, Production Deployments

Blazor Publish: Expert Tips for Efficient Deployment with Examples
This post is part 10 of 12 in the series Blazor Tutorial: Build Next-Gen Web Applications

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:

ModelWhat you deployTypical hostingKey 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 ServerASP.NET Core app (.dll, runtime if self‑contained), static assetsKestrel 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 proper Content-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 or DynamicDependency.

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

Should I always enable AOT for WASM?

No. Measure. AOT boosts CPU‑heavy components, but it increases download size and build time. Start targeted; expand only if your metrics say so.

My WASM app is 10+ MB. What’s a realistic budget?

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.

Do I need sticky sessions with Blazor Server?

Yes – unless you front it with Azure SignalR Service. Each user’s circuit must hit the same instance.

Is trimming safe on Server?

Safe after you audit reflection usage. Keep PublishTrimmed=false until you annotate or remove dynamic access patterns.

How do I store secrets for WASM?

You don’t. WASM runs on the client; any secret would be public. Put secrets behind a server API and call that.

Best way to manage environment config for WASM?

Serve a small config.{env}.json per environment and fetch it at startup; avoid bundling production endpoints in debug builds.

My PWA doesn’t update. Users are stuck.

Signal updates via service worker events and prompt a reload. Also consider Cache-Control: no-store for index.html.

Which Azure SKU for Blazor Server?

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.

Leave a Reply

Your email address will not be published. Required fields are marked *