Containerising Blazor Applications With Docker (Part 2)


This is the second post in the series: Containerising Blazor Applications With Docker.

Part 1 - Containerising a Blazor Server App
Part 2 - Containerising a Blazor WebAssembly App (this post)
Part 3 - Publishing to Azure Container Registry using Azure Pipelines


In part 1 of the series, we took a look Docker and some of its key concepts. Then we took the default template for a Blazor Server app and containerised it using Docker. In this post, we are going to take a look at doing the same thing but with a Blazor WebAssembly app.

All the code for this post is available on GitHub.

Different Challenges

Creating a dockerfile for a Blazor Server app was pretty trivial. In-fact, if you use Visual Studio then it generates the file automatically for you with just a couple of clicks, albeit with some quirks.

Blazor WebAssembly projects present us with a different challenge, when published they produce static files. Unlike Blazor Server apps, we don't need the .NET Core runtime to serve them. This means we can drop the .NET Core runtime Docker image we used in part 1 as the base for our final image. So how are we going to serve our files? The answer is NGINX.

What is NGINX?

If you've not come across it before, NGINX is a free and open source web server which can also be used as a reverse proxy, load balancer and HTTP cache. It's really great at serving static content, fast. When compared to apache it uses significantly less memory and can handle up to 4 times the number of requests per second.

Of course there's a Docker image for NGINX, several versions in-fact, but the one we'll be looking to use is NGINX:Alpine. This is a really tiny image, less than 5mb!! And it has everything we'll need to serve our Blazor WebAssembly application.

Prerequisites

If you've not done any work with Docker before you will need to install Docker Desktop for Windows or Docker Desktop for Mac. Just follow the setup instructions and you will be up and running in a couple of minutes. For the purpose of this post we're going to be using the default project template for a Blazor WebAssembly app. I'm going to be working in VS Code for this project but use whatever IDE/Editor you choose.

Adding NGINX Configuration

We're going to be using NGINX to serve our application inside our container however, as our app is a SPA (Single Page Application), we need to tell NGINX to route all requests to the index.html.

As NGINX configuration is all opt-in it doesn't handle different mime types unless we tell it to. Also we will need to add in a mime type for wasm as this is not included in NGINXs default mime type list.

In the root of the project add a new file called nginx.conf and add in the following code.

events { }
http {
    include mime.types;
    types {
        application/wasm wasm;
    }

    server {
        listen 80;

        location / {
            root /usr/share/nginx/html;
            try_files $uri $uri/ /index.html =404;
        }
    }
}

This is a really bare bones configuration which will allow our app to be served. But if you're looking to move into production with this then I would highly recommend you head over to the NGINX docs site and have a read of all the options you can configure.

Essentially we've setup a simple web server listening on port 80 with files being served from /usr/share/nginx/html. The try_files configuration tells NGINX to serve the index.html whenever it can't find the requested file on disk.

Above the server block we've included the default mime types as well as a custom mime type for wasm files.

Adding a Dockerfile

Now let's add a dockerfile to the root of our project with the following code.

FROM mcr.microsoft.com/dotnet/core/sdk:3.0-buster AS build
WORKDIR /src
COPY BlazorWasmDocker.csproj .
RUN dotnet restore "BlazorWasmDocker.csproj"
COPY . .
RUN dotnet build "BlazorWasmDocker.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "BlazorWasmDocker.csproj" -c Release -o /app/publish

FROM nginx:alpine AS final
WORKDIR /usr/share/nginx/html
COPY --from=publish /app/publish/BlazorWasmDocker/dist .
COPY nginx.conf /etc/nginx/nginx.conf

Just as we did in part 1, let's break this down a section at a time to understand what is going on.

FROM mcr.microsoft.com/dotnet/core/sdk:3.0-buster AS build
WORKDIR /src
COPY BlazorWasmDocker.csproj .
RUN dotnet restore "BlazorWasmDocker.csproj"
COPY . .
RUN dotnet build "BlazorWasmDocker.csproj" -c Release -o /app/build

This first section is going to build our app. We're using Microsofts official .NET Core 3 SDK image as the base for the build. We set the WORKDIR in the container to /src and then COPY over the csproj file from our project. Next we run a dotnet restore before COPYing over the rest of the files from our project to the container. Finally, we build the project by RUNing dotnet build on our project file setting the configuration to release.

FROM build AS publish
RUN dotnet publish "BlazorWasmDocker.csproj" -c Release -o /app/publish

The next section publishes our app. This is pretty straightforward, we use the previous section as a base and then RUN the dotnet publish command to publish the project.

FROM nginx:alpine AS final
WORKDIR /usr/share/nginx/html
COPY --from=publish /app/publish/BlazorWasmDocker/dist .
COPY nginx.conf /etc/nginx/nginx.conf

The last section produces our final image. We use the nginx:alpine image as a base and start by setting the WORKDIR to /usr/share/nginx/html - this is the directory where we'll serve our application from. Next, we COPY over our published app from the previous publish section to the current working directory. Finally, we COPY over the nginx.conf we created earlier to replace the default configuration file.

Building the image

Now we have our dockerfile all setup and ready to go we need to build our image.

docker build -t blazor-webassembly-with-docker .

Just as in part 1, we're using the docker build command, the -t switch allows us to tag the image with a friendly name so we can identify it a bit easier later on. The dot (.) at the end tells docker to look for the dockerfile in the current directory.

The output from the build looks like this.

Sending build context to Docker daemon  12.67MB
Step 1/12 : FROM mcr.microsoft.com/dotnet/core/sdk:3.0-buster AS build
 ---> 3930ade3a33a
Step 2/12 : WORKDIR /src
 ---> Running in 5887e6f8651b
Removing intermediate container 5887e6f8651b
 ---> d2a6ace2af2a
Step 3/12 : COPY BlazorWasmDocker.csproj .
 ---> 89996f9b08b3
Step 4/12 : RUN dotnet restore "BlazorWasmDocker.csproj"
 ---> Running in 58339cda1a29
  Restore completed in 4.26 sec for /src/BlazorWasmDocker.csproj.
Removing intermediate container 58339cda1a29
 ---> 877f9732fb20
Step 5/12 : COPY . .
 ---> 5d524b3a005a
Step 6/12 : RUN dotnet build "BlazorWasmDocker.csproj" -c Release -o /app/build
 ---> Running in e21142de9958
Microsoft (R) Build Engine version 16.3.0-preview-19377-01+dd8019d9e for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Restore completed in 113.14 ms for /src/BlazorWasmDocker.csproj.
  You are using a preview version of .NET Core. See: https://aka.ms/dotnet-core-preview
  BlazorWasmDocker -> /app/build/BlazorWasmDocker.dll
  Processing embedded resource linker descriptor: mscorlib.xml
  Duplicate preserve in resource mscorlib.xml in mscorlib, Version=2.0.5.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e of System.Threading.WasmRuntime (All).  Duplicate uses (All)
  Type System.Reflection.Assembly has no fields to preserve
  Type Mono.ValueTuple has no fields to preserve
  Output action:     Link assembly: BlazorWasmDocker, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
  Output action:     Save assembly: System.Threading.Tasks.Extensions, Version=4.2.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
  Output action:     Save assembly: System.Text.Json, Version=4.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
  Output action:     Save assembly: System.Text.Encodings.Web, Version=4.0.4.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
  Output action:     Save assembly: System.Runtime.CompilerServices.Unsafe, Version=4.0.5.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
  Output action:     Save assembly: System.Numerics.Vectors, Version=4.1.4.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
  Output action:     Save assembly: System.Memory, Version=4.0.1.1, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
  Output action:     Save assembly: System.ComponentModel.Annotations, Version=4.2.1.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
  Output action:     Save assembly: System.Buffers, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
  Output action:     Save assembly: Mono.WebAssembly.Interop, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:     Save assembly: Microsoft.JSInterop, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:     Save assembly: Microsoft.Extensions.Primitives, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:     Save assembly: Microsoft.Extensions.Options, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:     Save assembly: Microsoft.Extensions.Logging.Abstractions, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:     Save assembly: Microsoft.Extensions.DependencyInjection.Abstractions, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:     Save assembly: Microsoft.Extensions.DependencyInjection, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:     Save assembly: Microsoft.Bcl.AsyncInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
  Output action:     Save assembly: Microsoft.AspNetCore.Metadata, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:     Save assembly: Microsoft.AspNetCore.Components.Web, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:     Save assembly: Microsoft.AspNetCore.Components, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:     Save assembly: Microsoft.AspNetCore.Blazor.HttpClient, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:     Save assembly: Microsoft.AspNetCore.Blazor, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:     Save assembly: Microsoft.AspNetCore.Authorization, Version=3.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60
  Output action:   Delete assembly: netstandard, Version=2.1.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
  Output action:     Link assembly: mscorlib, Version=2.0.5.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
  Output action:     Link assembly: System, Version=2.0.5.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
  Output action:     Link assembly: Mono.Security, Version=2.0.5.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756
  Output action:   Delete assembly: System.Xml, Version=2.0.5.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
  Output action:   Delete assembly: System.Numerics, Version=2.0.5.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
  Output action:     Link assembly: System.Core, Version=2.0.5.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
  Output action:   Delete assembly: System.Data, Version=2.0.5.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
  Output action:   Delete assembly: System.Drawing.Common, Version=4.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51
  Output action:   Delete assembly: System.IO.Compression, Version=2.0.5.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
  Output action:   Delete assembly: System.IO.Compression.FileSystem, Version=2.0.5.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
  Output action:   Delete assembly: System.ComponentModel.Composition, Version=2.0.5.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
  Output action:     Link assembly: System.Net.Http, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
  Output action:   Delete assembly: System.Runtime.Serialization, Version=2.0.5.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
  Output action:   Delete assembly: System.Transactions, Version=2.0.5.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
  Output action:   Delete assembly: System.Web.Services, Version=2.0.5.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
  Output action:   Delete assembly: System.Xml.Linq, Version=2.0.5.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35
  Output action:   Delete assembly: System.ServiceModel.Internals, Version=0.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35
  Output action:   Delete assembly: System.Runtime, Version=4.1.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
  Writing boot data to: /src/obj/Release/netstandard2.0/blazor/blazor.boot.json
  Blazor Build result -> 34 files in /app/build/dist

Build succeeded.
    0 Warning(s)
    0 Error(s)

Time Elapsed 00:00:06.48
Removing intermediate container e21142de9958
 ---> 2baa60070e68
Step 7/12 : FROM build AS publish
 ---> 2baa60070e68
Step 8/12 : RUN dotnet publish "BlazorWasmDocker.csproj" -c Release -o /app/publish
 ---> Running in 8dbc22b721d6
Microsoft (R) Build Engine version 16.3.0-preview-19377-01+dd8019d9e for .NET Core
Copyright (C) Microsoft Corporation. All rights reserved.

  Restore completed in 18.34 ms for /src/BlazorWasmDocker.csproj.
  You are using a preview version of .NET Core. See: https://aka.ms/dotnet-core-preview
  BlazorWasmDocker -> /src/bin/Release/netstandard2.0/BlazorWasmDocker.dll
  Blazor Build result -> 34 files in /src/bin/Release/netstandard2.0/dist
  BlazorWasmDocker -> /app/publish/
Removing intermediate container 8dbc22b721d6
 ---> fbcddda6346a
Step 9/12 : FROM nginx:alpine AS final
 ---> 41c8c3458a93
Step 10/12 : WORKDIR /usr/share/nginx/html
 ---> Using cache
 ---> 6130c816d7b1
Step 11/12 : COPY --from=publish /app/publish/BlazorWasmDocker/dist .
 ---> a59305f74889
Step 12/12 : COPY nginx.conf /etc/nginx/nginx.conf
 ---> 62ec166940dc
Successfully built 62ec166940dc
Successfully tagged blazor-webassembly-with-docker:latest

Starting a container

Now we have built our image we can go ahead and start a container and check if everything is working.

docker run -p 8080:80 blazor-webassembly-with-docker

This command tells Docker to start a container with the tag blazor-webassembly-with-docker. The -p switch maps port 8080 on the host to port 80 in the container.

Once you have run the command then open a browser and navigate to http://localhost:8080 and you should be able to load the app.

Detached Mode

If you want to leave your container running but you don't want it hogging a terminal window, you can start it in detached mode. This mode runs the container in the background so it doesn't receive any inputs or display any outputs. To use detached mode add the -d switch to the docker run command.

docker run -d -p 8080:80 blazor-webassembly-with-docker

When executed you'll see the unique identifier for your container appear on the screen and then you'll be returned back to the terminal prompt.

To view any container you currently have running in the background you can use the docker ps command.

If you want to stop a container running in the background then use the docker stop command with either the containers ID or name.

docker stop youthful_wozniak

Summary

In this post, we've looked at the different challenges we face running a Blazor WebAssembly application in a container. We then built an image for our app which uses NGINX to serve the static content which Blazor WebAssembly applications produce. We finished up be checking everything worked by starting a container using our new image.

Next time we'll take a look at how we can automate building and deploying with Azure DevOps and hopefully get our containers running on Azure.