Introduction
The Blazor development system allows web applications to be built using C#. These applications can be hosted on a variety of platforms including IIS, Azure, and Linux with Nginx and Apache. The Visual Studio and VS Code project templates that come with the Blazor package are not designed to allow multiple applications to run side-by-side on Apache, however.
This post will demonstrate how to host multiple Blazor applications on Apache, and will also present some ideas for organizing the server to simplify deployment.
Source Code
The source for this post is available on GitHub.
The Test Environment
The Linux server used for this article was set up on a local network with the following:
- Ubuntu 22.04.1 (LTS)
- Apache 2.4.52 (Ubuntu)
- dotnet 9.0 – note that Ubuntu has an apt-get-installable package for dotnet, although you may need to use aptitude to install it depending on your system
A DNS was also set up (using bind9) to allow virtual hosts to be defined. The Linux server was set up to serve www.home.internal and apps.home.internal on the local network.
The default Apache distribution for Ubuntu is based on the Debian packaging, which has some differences in configuration from the base product. In particular, INCLUDEs are used to split the configuration up into separate files for virtual sites, modules, and extra configuration sections.
Commands are provided to enable/disable these sections:
- a2ensite
- Enable a site configuration in /etc/apache2/sites-available by creating a symbolic link to it in /etc/apache2/sites-enabled. Use a2dissite to disable it.
- a2enmod
- Enable a module configuration in /etc/apache2/mods-available by creating a symbolic link to it in /etc/apache2/mods-enabled. Use a2dismod to disable it.
- a2enconf
- Enable an extra configuration in /etc/apache2/conf-available by creating a symbolic link to it in /etc/apache2/conf-enabled. Use a2disconf to disable it.
After using these commands it is necessary to reload Apache so that the changes to the configuration get processed.
The examples in this article will follow this convention.
A virtual host was set up for apps.home.internal using /etc/apache2/sites-available/apps-basic.conf:
<VirtualHost *:80>
# virtual host for Blazor apps
ServerName apps.home.internal
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html/apps
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
This was enabled in the base configuration:
sudo a2ensite apps-basic
sudo systemctl reload apache2
The apps were primarily tested on Windows using Microsoft Edge, although other browsers and client environments were tested to check for compatibility. If you follow the examples in this post step-by-step, it is likely that you will need to clear the browser cache frequently to observe the results described.
Deploying Blazor Apps
The Blazor framework includes a number of templates that can be used to build Blazor apps in Visual Studio or VS Code. These templates can be broadly split into those that build standalone WebAssembly apps and those that build server-side apps.
Hosting Stand-Alone Blazor WebAssembly Apps
Standalone apps generally contain a single project and can be hosted simply.
To build and deploy an example stand-alone WebAssembly app:
- Create a Blazor WebAssembly Standalone App project called BlazorStandaloneApp
- Use Authentication type – None. Blazor authentication will be discussed in a future post.
- Include sample pages. This will provide a basic app for demonstration purposes.
- Publish the project to a local folder, e.g. bin\release\net9.0\browser-wasm\publish
- Copy the contents of publish\wwwroot to the Linux server app folder /var/www/html/apps/BlazorStandaloneApp. Note that you can not just publish the project directly to the Linux server app folder from Visual Studio because there is an extra level in the publish directory.
If you navigate to apps.home.internal/BlazorStandaloneApp you will see a less-than-desirable result.

Looking at the Apache access log reveals the issue:
... "GET /BlazorStandaloneApp/ HTTP/1.1" 200 815 "-" ...
... "GET /lib/bootstrap/dist/css/bootstrap.min.css HTTP/1.1" 404 496 http://apps.home.internal/BlazorStandaloneApp/" ...
... "GET /css/app.css HTTP/1.1" 404 497 "http://apps.home.internal/BlazorStandaloneApp/" ...
... "GET /BlazorStandaloneApp.styles.css HTTP/1.1" 404 496 "http://apps.home.internal/BlazorStandaloneApp/" ...
... "GET /_framework/blazor.webassembly.js HTTP/1.1" 404 497 "http://apps.home.internal/BlazorStandaloneApp/" ...
The CSS and script files are not being fetched from the app folder. The problem is caused by the way the default wwwroot/index.html is coded:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BlazorStandaloneApp</title>
<base href="/" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="BlazorStandaloneApp.styles.css" rel="stylesheet" />
</head>
<body>
<!-- ... -->
<script src="_framework/blazor.webassembly.js"></script>
</body>
</html>
The stylesheet, icon, and script references are all relative, but the base directive causes the files to fetched from the document root.
The simplest fix for this is to change the base to use the deployed app root:
<base href="/BlazorStandaloneApp/" />
This introduces an unfortunate dependency on the deployment location. It also makes it difficult to run the app in the debug environment.
A better solution is to use relative referencing throughout by removing the base directive altogether. Now the app mostly works but the home link on the navigation menu is broken.
To fix this:
- In Pages/Home.razor, add an extra @page directive for “home”:
@page "/"
@page "/home"
...
- In Layout/NavMenu.razor, change the first NavLink from
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
to
<NavLink class="nav-link" href="home" Match="NavLinkMatch.All">
- Re-publish/redeploy
Now the app should run as expected.

To host multiple standalone apps simply ensure that they have unique deployment directory names.
Note: the Microsoft deployment documentation for standalone WebAssembly apps provides some configuration changes for Apache.
I made the configuration changes more generic by creating /etc/apache2/conf-available/blazor-webassembly.conf:
AddType application/wasm .wasm
<IfModule mod_filter.c>
AddOutputFilterByType DEFLATE text/css
AddOutputFilterByType DEFLATE application/javascript
AddOutputFilterByType DEFLATE text/html
AddOutputFilterByType DEFLATE application/octet-stream
AddOutputFilterByType DEFLATE application/wasm
<IfModule mod_setenvif.c>
BrowserMatch ^Mozilla/4 gzip-only-text/html
BrowserMatch ^Mozilla/4.0[678] no-gzip
BrowserMatch bMSIE !no-gzip !gzip-only-text/html
</IfModule>
</IfModule>
If you wish to use these changes you will need to enable them:
sudo a2enconf blazor-webassembly
sudo systemctl reload apache2
I have not noticed any differences with these changes in my testing; it may be that they are necessary for certain browsers.
Hosting Server-Side Blazor WebAssembly Apps
Deploying Blazor server-side apps is a little more involved than for standalone apps.
To begin:
- Create a Blazor Web App project called BlazorWebApp
- Specify Authentication type – None.
- The Interactive render mode and Interactivity location don’t really matter for this purpose.
- Publish the project to the Linux server app folder /var/html/apps/BlazorWebApp
In the Linux app folder there should be a file called BlazorWebApp.dll, which is the Blazor server component that supports the app. Open a new Linux command line, navigate to /var/html/apps/BlazorWebApp, and run:
dotnet BlazorWebApp.dll
The Blazor server component is now accepting requests on port 5000.
In order for web requests to be forwarded to this component Apache needs to be configured with some proxy directives. The Microsoft deployment documentation for server-side apps provides some help with this.
You can set this up in /etc/apache2/sites-available/apps-simple-proxy.conf:
<VirtualHost *:80>
# virtual host for Blazor apps
ServerName apps.home.internal
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html/apps
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
ProxyPreserveHost On
ProxyPassMatch ^/_blazor/(.*) http://localhost:5000/_blazor/$1
ProxyPass /_blazor ws://localhost:5000/_blazor
ProxyPass / http://localhost:5000/
ProxyPassReverse / http://localhost:5000/
</VirtualHost>
Make this configuration active:
sudo a2dissite apps-basic
sudo a2ensite apps-simple-proxy
You will also need to enable these Apache mods for Blazor proxying to work:
sudo a2enmod proxy
sudo a2enmod proxy_wstunnel
Reload Apache to put all of these changes into effect:
sudo systemctl reload apache2
If you navigate to apps.home.internal you will see the running app.

The problem now is that this is the only app that the server will offer on this virtual site. Navigating to apps.home.internal/BlazorStandaloneApp no longer works. This will be addressed shortly.
How it Works
The proxy directives set up in the apps virtual site configuration cause web requests to be forwarded to a remote server on port 5000, which in our case is the Blazor server component.
The effects of these directives are:
- ProxyPreserveHost On
- Pass the host name from the web request to the remote server.
- ProxyPassMatch ^/_blazor/(.*) http://localhost:5000/_blazor/$1
- For web requests of the form “/_blazor/…”, pass the request to the remote server using http.
- ProxyPass /_blazor ws://localhost:5000/_blazor
- For web requests starting with “/_blazor”, pass the request to the remote server using the websocket protocol.
- ProxyPass / http://localhost:5000/
- Pass all web requests to the remote server using http.
- ProxyPassReverse / http://localhost:5000/
- Adjust the headers in any redirect response from the remote server so that as far as the browser is concerned the redirects look like they are coming from Apache directly.
The ProxyPass… directives are tried against a request in the order they appear in the configuration file, with the first directive with a pattern matching the request being used.
The initial app request is processed as follows:
- The browser issues an HTTP GET for “/” to apps.home.internal.
- Apache recognizes the apps.home.internal virtual host and processes its configuration (from /etc/apache2/sites-enabled/apps-simple-proxy.conf).
- Since the request does not start with “/_blazor”, the first proxy directive with a matching pattern for the request is “ProxyPass / http://localhost:5000/”.
- Apache forwards the request to port 5000, which is being listened to by the Blazor server component BlazorWebApp.dll.
- The Blazor server component processes the request at the default endpoint “/”. The initial app page is assembled and presented to the browser.
- The browser issues GETs for “/app.css”, “/_framework/blazor.web.js”, /BlazorWebApp/styles.css”, “/bootstrap/bootstrap.min.css”, and “/favicon.png”.
- Apache again uses the last ProxyPass directive to forward these requests to the Blazor server component.
- The Blazor server component processes these requests using the “Blazor web static files” endpoint. The files are presented to the browser.
For this simple app, none of the other proxy directives are used.
Hosting Multiple Server-Side Apps
There are a couple of ways that multiple apps can be supported. One possibility is to program the app so that it supports extra endpoints for the extra app names. An example is shown in this Microsoft documentation. The approach described might be suitable for hosting related server-side apps with a common backend server (e.g. a database), but does not permit the previously deployed standalone app to be supported.
A more generic approach would be to use more specific patterns in the configured proxy directives.
The ProxyPassMatch directive uses a regular expression substitution to create the remote server request from the incoming request. If the incoming request is “/_blazor/abc.def”, for example, then the directive shown in the previous section will pass “/_blazor/abc.def” to the backend server via http.
The ProxyPass directive takes the portion of the incoming request that follows the pattern specified and appends that to the remote server url. If the incoming request is “/_blazor?abc=def”, for example, then the first ProxyPass directive above will pass “/_blazor?abc=def” to the backend server via the websocket protocol.
If we use modified proxy directives as in /etc/apache2/sites-available/apps-restricted-proxy.conf then we will have a more restricted set of requests that will be forwarded:
<VirtualHost *:80>
# virtual host for Blazor apps
ServerName apps.home.internal
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html/apps
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
ProxyPreserveHost On
ProxyPassMatch ^/BlazorWebApp/_blazor/(.*) http://localhost:5000/_blazor/$1
ProxyPass /BlazorWebApp/_blazor ws://localhost:5000/_blazor
ProxyPass /BlazorWebApp http://localhost:5000/
ProxyPassReverse /BlazorWebApp http://localhost:5000/
</VirtualHost>
Enable the changes:
sudo a2dissite apps-simple-proxy
sudo a2ensite apps-restricted-proxy
sudo systemctl reload apache2
Now navigating to apps.home.internal/BlazorStandaloneApp works properly. Navigating to apps.home.internal/BlazorWebApp does not display properly, for reasons similar to what was at issue with the standalone app previously.

Looking at the Apache access log again reveals the issue:
... "GET /BlazorWebApp/ HTTP/1.1" 200 1862 "-" ...
... "GET /lib/bootstrap/dist/css/bootstrap.min.<generated>.css HTTP/1.1" 404 496 "http://apps.home.internal/BlazorWebApp/" ...
... "GET /app.<generated>.css HTTP/1.1" 404 497 "http://apps.home.internal/BlazorWebApp/" ...
... "GET /_framework/blazor.web.js HTTP/1.1" 404 497 "http://apps.home.internal/BlazorWebApp/" ...
... "GET /BlazorWebApp.<generated>.styles.css HTTP/1.1" 404 497 "http://apps.home.internal/BlazorWebApp/" ...
The areas of the log marked “<generated>” are names generated during build and will be unique for your deployment.
The CSS and script files are being fetched from the document root. Since the proxy directives are now matching on requests starting with /BlazorWebApp, none of these directives apply to the CSS and script requests, and thus these requests are not being forwarded to the Blazor server component. Apache does not know how to find these files from the virtual host document root /var/html/app, so the requests fail.
Note that the BlazorWebApp solution contains two projects: BlazorWebApp and BlazorWebApp.Client. Both projects have required fixes. The problem starts with App.razor in the main project. The fix is similar to that for the standalone app:
- Remove the base directive from BlazorWebApp/Components/App.razor.
- In BlazorWebApp.Client/Components/Pages/Home.razor, add an extra @page directive for “home”:
@page "/" @page "/home" ...
- In BlazorWebApp.Client/Components/Layout/NavMenu.razor, change the first NavLink from
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
to
<NavLink class="nav-link" href="home" Match="NavLinkMatch.All">
- Republish/redeploy.
- Go to the command line where you ran the dotnet command to start the Blazor component.
- Hit cntl-c to stop the running component.
- Re-run the component:
dotnet BlazorWebApp.dll
- Refresh the browser

For running multiple server-side apps there is one more consideration: running the Blazor server component as above opens port 5000 by default. This port can not be shared with other programs, including other Blazor apps. For multiple server-side apps to run side-by-side, different ports will need to be used for each app.
The port is assigned when the server component is started. To change the port to 5001, for example, specify it when running the component:
dotnet BlazorWebApp.dll --urls http://localhost:5001
Setting up a Manageable Configuration
Deploying server-side Blazor apps as above can be complex and thus error-prone.
For each app:
- A unique port number needs to be assigned.
- The assigned port number needs to be used to run the server component.
- The assigned port number also needs to be used to set up the proxy directives for the deployed location.
We can take some ideas from the Debian Apache configuration approach to organize the deployment artifacts.
The following is a more structured approach to deploying BlazorWebApp than that described in the previous sections.
- Create the app-specific folders /etc/apache2/apps-base and /etc/apache2/apps-deployed.
- Create /etc/apache2/sites-available/apps-multi-proxy.conf:
<VirtualHost *:80> # virtual host for Blazor apps ServerName apps.home.internal ServerAdmin webmaster@localhost DocumentRoot /var/www/html/apps ErrorLog ${APACHE_LOG_DIR}/error.log CustomLog ${APACHE_LOG_DIR}/access.log combined # Proxy directives for deployed Blazor apps ProxyPreserveHost On IncludeOptional apps-deployed/*.conf </VirtualHost>
- Create a configuration file for the deployed app as apps-base/BlazorWeb.conf:
ProxyPassMatch ^/BlazorWebApp/_blazor/(.*) http://localhost:5000/_blazor/$1 ProxyPass /BlazorWebApp/_blazor ws://localhost:5000/_blazor ProxyPass /BlazorWebApp/ http://localhost:5000/ ProxyPassReverse /BlazorWebApp/ http://localhost:5000/
- Create a symbolic link in app-deployed to apps-base/BlazorWebApp.conf:
sudo ln -s /etc/apache2/apps-base/BlazorWebApp.conf /etc/apache2/apps-deployed/BlazorWebApp.conf
- Apply changes:
sudo a2dissite apps-restricted-proxy sudo a2ensite apps-multi-proxy.conf sudo systemctl reload apache2
You will also need to set up a daemon to run the server component for the deployed app.
- Set up a start script apps-base/start-server. This script will create a log in the deployment directory to help with debugging:
#!/bin/sh # # Start Blazor server component # # Parameters: # - deployment directory, relative to /var/html/apps # - component file name (usually the DLL file) # - port number cd /var/www/html/apps/$1 dotnet $2 --urls http://localhost:$3 >> `date +%Y%m%d`-start.log
- Create a service file apps-base/app-BlazorWebApp.service:
[Unit] Description=Simple Blazor Server-Side App [Service] ExecStart=/etc/apache2/apps-base/start-server BlazorWebApp BlazorWebApp.dll 5000 [Install] WantedBy=multi-user.target
- If the Blazor server component BlazorWebApp.dll is still running from the command line from a previous step, go to that command line and stop the server by hitting cntl-c. If the previously started component is still running then starting the new service in the next step will fail trying to open port 5000.
- Start the new service:
sudo systemctl enable /etc/apache2/apps-base/app-BlazorWebApp.service sudo systemctl start app-BlazorWebApp
To deploy another Blazor web app, repeat steps 3, 4, 6, and 7 with new app names and ports. You will need to restart Apache to enable the new configuration file, and enable and start the new service.
The app configuration file BlazorWebApp.conf and the service file app-BlazorWebApp.service need to use the same port number. Consider using templates for these files and use a deployment script to assign a new port number and to create and enable the files. I have created a Blazor app for this to allow me to deploy apps from my development machine to my test Linux server.
This approach can be extended to other virtual sites. You could, for example, have a test site named appstest which picks up configurations from an apps-in-test directory.
Summary
The Blazor platform allows for quite sophisticated web applications to be created and hosted using a variety of server environments. The system is designed, however, for apps to be hosted on dedicated hosts using containers or similar technology. This post has shown how some fairly small changes allow for multiple Blazor apps to be hosted on the same server using Linux with Apache.
0 Comments