Friday, June 5, 2020

Adding Docker to the ASP.NET Core Angular Template


What’s better than starting a new greenfield project? You finally have the opportunity to leverage all the new patterns, technologies, and frameworks that you’ve been dying to get your hands on. A project I started recently had a strong focus on Docker. Being a new project, we were also able to use the latest version of ASP.NET Core and Visual Studio 2019. If you’ve read any of my previous posts, you know that .NET Core and Visual Studio have a lot of very convenient interaction points with Docker.

Things that would normally take a couple hours to setup are created with the click of a button. This is of course until you use the ASP.NET Core Angular template and noticed the Enable Docker Support checkbox is disabled. If only life were so simple. Fortunately with a couple small changes, we are able to incorporate Docker into the default ASP.NET Core Angular template and take advantage of a benefits that come along with the Visual Studio container tools.

In this article, we will discuss enhancing the default template configuration to build and run your ASP.NET Core Angular app in Docker.

THE ANGULAR PROJECT TEMPLATE

When we create a new ASP.NET Core Web Application using the Angular template, a new “Hello, World” application is generated. In addition to the Angular frontend, we also have an ASP.NET Core API setup server-side. Debugging the project in Visual Studio, we will notice both the Angular and ASP.NET Core applications are running together.

If you’ve worked on a project like this before, you are probably used to debugging your frontend with the Angular CLI and your API with Visual Studio. The Angular project template appears to be doing both for us! But how? We can see this by looking at the Startup.cs file.

public class Startup
{
// Removed for brevity
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// Removed for brevity
app.UseSpa(spa =>
{
// To learn more about options for serving an Angular SPA from ASP.NET Core,
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseAngularCliServer(npmScript: "start");
}
});
}
}

As we can see, npm start is getting executed under the hood by leveraging the Microsoft.AspNetCore.SpaServices nuget package. Looking at our package.json file, we can see this is effectively calling ng serve.

{
"name": "jrtech.angular.docker",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"build:ssr": "ng run JrTech.Angular.Docker:server:dev",
"test": "ng test",
"lint": "ng lint",
"e2e": "ng e2e"
},
...
}

All this bridges the frontend and backend together nicely however, it doesn’t come without some downsides. First and foremost, when a backend change is made, ng serve needs to run again which can take 10+ seconds depending on the size of the frontend application. Microsoft mentions this in their documentation and offers a convenient workaround as shown in the Startup class below.

app.UseSpa(spa =>
{
// To learn more about options for serving an Angular SPA from ASP.NET Core,
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
// spa.UseAngularCliServer(npmScript: "start");
spa.UseProxyToSpaDevelopmentServer("http://localhost:4200");
}
});

Making this change allows us to debug our Angular application from the command-line while using Visual Studio to debug our ASP.NET Core API.

INTRODUCING DOCKER

While we have a nice development environment setup, let’s circle back to the beginning of the article. On this new project, we wanted to leverage Docker as much as possible. So how does Docker fit into our new project? Well at the moment it doesn’t but, we can fix that!

Just like anything else, we have a couple options. For example, we could take the entire project and throw it into a container. This of course would work however, it will have the same issues as previously discussed. Any changes to the backend code would require us to rebuild the backend AND frontend projects and vise versa. To build them independently, we will have to split them into separate containers.

First lets take a look at running our ASP.NET Core API in Docker.

ASP.NET CORE WITH DOCKER

Even though we can’t include Docker support when we create an Angular project, we can still add Docker support after the fact from the project menu. This will automatically generate a Dockerfile similar to the one shown below.

#See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging.
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build
WORKDIR /src
COPY ["JrTech.Angular.Docker/JrTech.Angular.Docker.csproj", "JrTech.Angular.Docker/"]
RUN dotnet restore "JrTech.Angular.Docker/JrTech.Angular.Docker.csproj"
COPY . .
WORKDIR "/src/JrTech.Angular.Docker"
RUN dotnet build "JrTech.Angular.Docker.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "JrTech.Angular.Docker.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "JrTech.Angular.Docker.dll"]

At this point if we try to debug our project, the browser will display a “Failed to proxy the request to http://localhost:4200/” error message. As the error message states, our ASP.NET project is unable access our Angular application. Since ASP.NET Core is now running in a container, we will get the same error message even if ng serve is running locally. We can solve this by continuing our with our journey into Docker.

ANGULAR WITH DOCKER

The next step is to configure our Angular application to run in Docker as well. For this, we will create a separate Dockerfile. This gives us the flexibility to stop and start our API without rebuilding the frontend. I like to use a Dockerfile similar to the one shown below.

FROM node:10.15-alpine AS client
EXPOSE 4200 49153
USER node
RUN mkdir /home/node/.npm-global
ENV PATH=/home/node/.npm-global/bin:$PATH
ENV NPM_CONFIG_PREFIX=/home/node/.npm-global
RUN npm install -g @angular/cli@8.1.0
WORKDIR /app
CMD ["ng", "serve", "--port", "4200", "--host", "0.0.0.0", "--disable-host-check", "--poll", "2000"]

The resulting Docker image image will contain node, npm, and the Angular CLI. When the container is started, ng server will be executed in the app folder. Of course the app folder will be empty but, we can make our code accessible to that location by mounting a volume in the container.

BRINGING IT ALL TOGETHER

To run these two containers together, we use a tool that goes hand-in-hand with Docker called Docker Compose. If you are not familar with Docker Compose, it is a container orchestration too that is used for configuring and running multi-container Docker applications. When using Docker Compose everything gets configured in a yml configuration file. We can create a create a docker-compose.yml file in Visual Studio by right-clicking the project and selecting Add | Container Orchestration Support.

After it is created, our Angular container will need to be included, as shown below.

version: '3.4'
services:
jrtech.angular.docker:
image: ${DOCKER_REGISTRY-}jrtechangulardocker
build:
context: .
dockerfile: JrTech.Angular.Docker/Dockerfile
jrtech.angular.app:
image: ${DOCKER_REGISTRY-}jrtechangularapp
build:
context: .
dockerfile: JrTech.Angular.Docker/ClientApp/Dockerfile
ports:
- "4200:4200"
- "49153:49153"
volumes:
- ./JrTech.Angular.Docker/ClientApp:/app

Lastly, we will need to update our Startup.cs file to proxy requests to the Angular container. We use the service name (jrtech.angular.app) in order to work with Docker’s internal network.

app.UseSpa(spa =>
{
// To learn more about options for serving an Angular SPA from ASP.NET Core,
spa.Options.SourcePath = "ClientApp";
if (env.IsDevelopment())
{
spa.UseProxyToSpaDevelopmentServer("http://jrtech.angular.app:4200");
}
});

Voila! Now if we set the docker-compose project as our startup project and debug, both containers will start running. If we make some changes and rerun the debugger, it will be fast as the Angular container never stops!

DEPLOYING AS A SINGLE CONTAINER

We now have a nice development process setup however, we may not want to deploy in this configuration. While nice for local development, deploying our frontend and backend applications separately means we will have to deal with CORSpreflight requests, etc. If we want to avoid this, we can support a single container deployment by making a few tweaks to our Dockerfile.

By default, Visual Studio creates a multistage Dockerfile. We can extend this by adding an additional stage for our Angular application. We also introduce a build time argument which will dictate if we want to build the frontend components or not.

FROM mcr.microsoft.com/dotnet/core/aspnet:3.1-buster-slim AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM node:10.15-alpine AS client
ARG skip_client_build=false
WORKDIR /app
COPY JrTech.Angular.Docker/ClientApp .
RUN [[ ${skip_client_build} = true ]] && echo "Skipping npm install" || npm install
RUN [[ ${skip_client_build} = true ]] && mkdir dist || npm run-script build
FROM mcr.microsoft.com/dotnet/core/sdk:3.1-buster AS build
WORKDIR /src
COPY ["JrTech.Angular.Docker/JrTech.Angular.Docker.csproj", "JrTech.Angular.Docker/"]
RUN dotnet restore "JrTech.Angular.Docker/JrTech.Angular.Docker.csproj"
COPY . .
WORKDIR "/src/JrTech.Angular.Docker"
RUN dotnet build "JrTech.Angular.Docker.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "JrTech.Angular.Docker.csproj" -c Release -o /app/publish
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
COPY --from=client /app/dist /app/dist
ENTRYPOINT ["dotnet", "JrTech.Angular.Docker.dll"]

We give the argument a default value of false but, in our docker-compose.yml file, we override it to true. This way when we build our containers with docker-compose, the Angular application will not be redundantly built inside of the ASP.NET Core container.

version: '3.4'
services:
jrtech.angular.docker:
image: ${DOCKER_REGISTRY-}jrtechangulardocker
build:
context: .
dockerfile: JrTech.Angular.Docker/Dockerfile
args:
- skip_client_build=true
jrtech.angular.app:
image: ${DOCKER_REGISTRY-}jrtechangularapp
build:
context: .
dockerfile: JrTech.Angular.Docker/ClientApp/Dockerfile
ports:
- "4200:4200"
- "49153:49153"
volumes:
- ./JrTech.Angular.Docker/ClientApp:/app

Lastly, we can remove the majority of the custom build configuration in our project file as all of our Angular build steps are now in Docker. I cleaned mine up to look very similar to a typical ASP.NET Core web application.

<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
<UserSecretsId>9803d20b-7c4b-45ad-b021-58160cf46b32</UserSecretsId>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="3.1.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.9.10" />
</ItemGroup>
</Project>

With this configuration, when running our application with docker-compose our Angular app is built in a separate container. When the container is built individually, all of the Angular components are self-contained within the ASP.NET Core container. This gives us the ability build, compile, and debug these applications while developing and generate a self-contained image for deployment.