If deploying a SPA in an ASP.NET Core site is in your future, this article is for you. I'll go over it in five steps: Creating a database, deploying a database, creating an app service, compiling and deploying the host, and compiling and deploying Angular.
I've deployed two apps based on ASP.NET Boilerplate (Angular + ASP.NET Core) through to production. The second used Azure Kubernetes Services. It's slick, sophisticated, self-healing, extremely scalable, uses infrastructure as code with a multi-stage Azure DevOps build pipeline, and it's micro-services are independently deploy-able.
That second site is awesome. It's also complicated, took a lot of effort to build, and will take a lot of effort to maintain. This is not its story.
This is the story of my first site, where I threw the SPA (Single Page Application) into ASP.NET Core's wwwroot directory, and slung it up to Azure App Services as a single site, and called it a day. Kind of mostly.
Anyway, it didn't need micro-services, massive scalability, self-healing, and all that mumbo jumbo. It just needed to be simple, quick to build, and easy to maintain.
If deploying a SPA in an ASP.NET Core site is in your future, you're in a hurry, and quick and easy sounds good: this article is for you. I'll go over it in five steps:
- Create Database
- Deploy Database
- Create App Service
- Compile and Deploy Host
- Compile and Deploy Angular
If you're already familiar with deploying Azure Resources via the Azure CLI skip to step 5. If you'd like to watch this as a video, check out E31 of Code Hour.
Otherwise, here's the how to create and deploy starting from zero, optimizing for simplicity:
1. Create Database
Obviously, we need a database. Using the Azure Portal is wonderful, but let's say we want repeatability. Perhaps for other environments (e.g. test/prod), or for subsequent projects. So let's use a script.
But how to avoid the problem of running it twice and creating duplicate resources? Parameters, right? And if
statements to ensure the resource doesn't already exist.
But what if we want to make a resource change, e.g., to the database SKU, and deploy it through to multiple environments? This is sounding complicated, and it's what products like Terraform are made for. With Terraform, we can describe the desired state using configuration as code and the tool looks at actual state and makes it happen, like magic.
Terraform sounds awesome (it is), but let's avoid 3rd party dependencies and not worry about idempotence at all for now. I'm also sorely tempted to use Cake and the world famous (not really) Cake.Azure CLI Plugin, but we're in a hurry, let's avoid all nonessential 3rd party dependencies 😢.
$resourceGroupName = "LeesStoreQuickDeploy"
$location = "eastus"
az login
az group create -l $location -n $resourceGroupName
$sqlUsername = "[username]"
$sqlPassword = "[password]"
$sqlName = "leesstorequickdeploy"
az sql server create -g $resourceGroupName -n $sqlName `
-u $sqlUsername -p $sqlPassword -l $location
$sqlDbName = "LeesStoreQuickDeploy"
az sql db create -g $resourceGroupName -s $sqlName -n $sqlDbName `
--compute-model "Serverless" -e "GeneralPurpose" -f "Gen5" `
-c 1 --max-size "1GB"
2. Deploy Database
Obviously, we need to get the database tables created and insert some data. That's exactly what the Entity Framework Migrations are for.
It'd be tempting to update appsetting.json with the connection string info from step 1 and run dotnet ef database update
or Update-Database
just like we did locally. However, that approach won't support the multi-tenancy multi-database scenarios granted by ASP.NET Boilerplate (see also Multi-Tenancy is Hard: ASP.NET Boilerplate Makes it Easy). Furthermore, if we ever do a multi-stage Azure DevOps pipeline, we'd want to publish a single asset that we could download and run in each stage for each environment.
Fortunately, this is exactly why ASP.NET Boilerplate provides the "Migrator" command line application. We just need to compile and run it.
$connectionString = "Server=leesstorequickdeploy.database.windows.net;
Initial Catalog=LeesStoreQuickDeploy;UID=lee;Password=[pwd];"
Remove-Item ".\dist\Migrator\*.*"
dotnet publish -c Release -o ".\dist\Migrator" `
-r "win-x64" /p:PublishSingleFile=true `
.\aspnet-core\src\LeesStore.Migrator
Copy-Item ".\aspnet-core\src\LeesStore.Migrator\log4net.config" `
".\dist\Migrator"
Push-Location
Set-Location ".\dist\Migrator"
$env:ConnectionStrings__Default = $connectionString
.\LeesStore.Migrator.exe -q
Pop-Location
3. Create App Service
✔ Databases & Migrations
❔ Webs of Azure
There are many options to deploy a website, even narrowing the universe to Azure. They all have pros and cons. Virtual Machines (IaaS) are expensive and require maintenance. Kubernetes is complicated. Azure's Web App for Containers Instances is pretty awesome, and ASP.NET Boilerplate is already containerized. But then we'd need to maintain the container image and it adds complexity.
App Services are simple and fast, and good enough for most scenarios:
$appServicePlan = "LeesStoreServicePlan"
$webAppName = "leesstore2"
az appservice plan create -n $appServicePlan -g $resourceGroupName --sku Free -l $location
az webapp create -n $webAppName -g $resourceGroupName -p $appServicePlan
4. Compile and Deploy Host
A website without code is like a taco without fillings: an abomination.
To fix it, first we have to set the connection string. The rest should be relatively easy, just dotnet publish
, compress the results, and upload them with an az
command:
az webapp config appsettings set -n $webappname -g $resourceGroupName
--settings ConnectionStrings__Default=$connectionString
dotnet publish -c Release -o ".\dist\Host" .\aspnet-core\src\LeesStore.Web.Host
Compress-Archive -PassThru -Path ".\dist\Host\*" -DestinationPath ".\dist\Host.zip"
az webapp deployment source config-zip -n $webappname -g $resourceGroupName
--src ".\dist\Host.zip"
Maybe not so easy. If we run that script, the chances are good we'll get some error about an IP restriction:
SqlException: Cannot open server requested by login. Client with IP Address is now allowed to access the server. To enable access, use the Windows Azure Management Portal or run sp_set_firewall_rule on the master database to create a firewall rule for this IP address or address range.
Either add the IP to the SQL Firewall, or allow all Azure resources to access it like:
az sql server firewall-rule create -g $resourceGroupName -s $sqlName `
-n Azure --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0
Problem solved! Of course, behind every good error, there's always another error:
Failed to fetch swagger.json. Possible mixed-content issue? The page was loaded over https:// but a http:// URL was specified. Check that you are not attempting to load mixed content.
This sneaky problem is most likely that ASP.NET Boilerplate depends on knowing the host URL, the Angular URL, and any other possible URLs for CORS. Because we're going to publish Angular at the same URL, we can set all three to the same URL, and per the error above, they're currently set to http://localhost:[someport]. At some point, we might add a custom URL and custom SSL cert, but for now, we can just override them with the azurewebsites.net setting:
$url = "https://leesstore2.azurewebsites.net/"
az webapp config appsettings set -n $webappname -g $resourceGroupName `
--settings `
App__ServerRootAddress=$url `
App__ClientRootAddress=$url `
App__CorsOrigins=$url
And with any luck Swagger! 😁
Swagger UI
5. Compile and Deploy Angular
That's great! But however much devs might like that UI, users won't exactly be blown away. We need the Angular site.
Step one is to compile the SPA. With Angular, that's as easy as ng build --prod
. Throwing on an --aot
, however, will catch more compiler errors, that's good. Then let's send the resulting site to a temporary location and move it to the Hosts's /wwwroot folder.
Push-Location
Set-Location ".\angular"
ng build --prod --aot --output-path "../dist/ng"
Pop-Location
Move-Item "dist/ng" "aspnet-core/src/LeesStore.Web.Host/wwwroot"
If we run the Host site now, it'll continue to throw up the Swagger site. That's because the call to app.UseStaticFiles()
in Startup.cs excludes default files, like Angular's oh so important index.html.
We could either add app.UseDefaultFiles(new DefaultFilesOptions());
or replace the app.UseStaticFiles()
call with app.UseFileServer()
and now if we run it locally (remembering to update the App
section of appsetting.json), then Angular works at our Host port of 21021!
LeesStore Login Page
Not bad, but there are still two problems.
Problem 1 - appconfig.json
If we rerun the deploy script now, things fail on the server:
GetAll net::ERR_CONNECTION_REFUSED
See the issue? The immediate error and the underlying problem are both in the screenshot.
The immediate error (in the Console) is that the site made a request to http://localhost:21021/.../GetAll. The underlying problem (in the Network tab's Response window) is that when it made a call to appconfig.production.json, both the remoteServiceBaseUrl
and the appBaseUrl
were wrong.
We could fix those problems in a variety of ways, but I especially like adding ASP.NET Core middleware to catch that request and dynamically generate the values based on what's in the appconfig.json (or in environment variables). Check this out:
public class Startup
{
...
public void Configure(...)
{
...
app.UseCors(_defaultCorsPolicyName);
app.UseDynamicAppConfig(_appConfiguration);
app.UseFileServer();
...
}
}
Ha, a setup, didn't see that coming, did you? We just used an undefined UseDynamicAppConfig()
extension method in Startup.cs/Configure()
. Again, this time for real:
public static class DynamicAppConfigMiddlewareBuilder
{
public static IApplicationBuilder UseDynamicAppConfig
(this IApplicationBuilder builder, IConfigurationRoot appConfiguration)
{
var serverRootAddress = appConfiguration["App:ServerRootAddress"];
var clientRootAddress = appConfiguration["App:ClientRootAddress"];
return builder.UseMiddleware<DynamicAppConfigMiddleware>
(serverRootAddress, clientRootAddress);
}
}
public class DynamicAppConfigMiddleware
{
private readonly RequestDelegate _next;
private readonly string _serverRootAddress;
private readonly string _clientRootAddress;
public DynamicAppConfigMiddleware(RequestDelegate next,
string serverRootAddress, string clientRootAddress)
{
_next = next;
_serverRootAddress = serverRootAddress;
_clientRootAddress = clientRootAddress;
}
public async Task Invoke(HttpContext context)
{
const string appConfigPath = "/assets/appconfig.production.json";
var isRequestingAppConfig = appConfigPath.Equals
(context.Request.Path.Value, StringComparison.CurrentCultureIgnoreCase);
if (isRequestingAppConfig)
{
string response = GenerateResponse();
await context.Response.WriteAsync(response);
}
else
{
await _next.Invoke(context);
}
}
private string GenerateResponse()
{
var cleanServerRootAddress = _serverRootAddress.TrimEnd('/');
var cleanClientRootAddress = _clientRootAddress.TrimEnd('/');
return $@"{{
""remoteServiceBaseUrl"": ""{cleanServerRootAddress}"",
""appBaseUrl"": ""{cleanClientRootAddress}""
}}";
}
}
That looks complicated, but it just intercepts any requests to /assets/appconfig.production.json and writes out a string json in GenerateResponse()
that includes values from the appConfiguration
.
I love the idea of dynamically generating that config file rather than duplicating values, it feels very clean. And it even works!
Rerun the deploy script, and holy cow!
Home page for uncustomized ASP.NET Boilerplate Site
We can log in and access the database!
And you know that's exciting because I just ended three paragraphs with exclamation points! (four? ugh, stupid off by one errors)
Problem 2: Angular Server-Side Routing
But behind any apparent success, there is at least ... one minor problem. In this case, if we refresh the page on any URL other than the root, we get a 404. This is a server-side routing problem. We need any requests to /app/[anything] to get rerouted to /index.html. Fortunately, that's easy to fix with a little more middleware magic. Check out the app.UseSpa();
call on line 11 of Startup.cs above. If we uncomment that and add the following middleware:
public static class SpaMiddlewareBuilder
{
public static void UseSpa(this IApplicationBuilder app)
{
app.Use(async (context, next) =>
{
await next();
if (context.Response.StatusCode == 404 &&
context.Request.Path.StartsWithSegments("/app") &&
!Path.HasExtension(context.Request.Path.Value))
{
context.Request.Path = "/index.html";
await next();
}
});
}
}
Now we can refresh on any page in Angular and it all just works.
🎉 Done!
We're done!! This warrants even more exclamation points!!!
SPA's add deployment complexity, but that deployment was pretty quick and easy. If you're interested in the source code, check out Pull Request 22 on LeesStore on GitHub. Now we just need to rewrite it all in Cake, use Terraform, and automate those scripts to run on the server. Tomorrow perhaps.
Was this interesting? I'd be happy to dig into more complex ABP deployment scenarios, just let me know in the comments or on twitter.