Moving Bookstore to the Cloud: ASP.NET Core on App Service, Containers on Container Apps and Static Hosting for Angular
Putting Bookstore.Api on App Service as code and on Container Apps as a container takes a few CLI commands; the real work is fitting the platform with health checks, forwarded headers and an SPA fallback. We cover plans versus apps, app setting and connection string prefixes, the /health and /health/live endpoints, X-Forwarded-Proto producing a wrong Location and its test, revisions and traffic splitting, and the Static Web Apps fallback for Angular, with real output.
Version note: The examples in this post were run with .NET 10 (SDK 10.0.404), Angular 22.2, Static Web Apps CLI 2.1.0, Docker Engine 29, a SQL Server 2025 container and Azure CLI 2.92.0. Nothing was deployed to Azure; the behavior of App Service, Container Apps and Static Web Apps comes from Microsoft Learn (November 2026), and CLI parameters were checked locally against --help output.
Guess First
The API of Bookstore, the online bookstore used as the shared example across this site's series, runs on App Service at https://app-bookstore-dev-weu.azurewebsites.net. The Angular application calls POST /api/carts to open a new cart; the API returns 201 Created along with the cart's address in the Location header. Does that address start with https:// or http://? Second question: when a user refreshes /books/9780451524935 in the browser, does Static Web Apps return 200 or 404? We saw both locally with real output.
App Service: Plan, App and .NET 10
App Service has two resources. The App Service plan defines the machine: operating system, size (such as B1 or P0v3) and instance count. The web app is the application running on that plan; a plan can hold several apps, and they all share the same instances. The script that deploys Bookstore.Api as code on Linux with the .NET 10 runtime:
#!/usr/bin/env bash
# Bookstore.Api on App Service: Linux plan, .NET 10 runtime, code deployment from a zip file.
set -euo pipefail
: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
ENVIRONMENT="${1:?Usage: 05-app-service.sh <dev|staging|prod>}"
SKU="${SKU:-B1}"
RG="rg-bookstore-${ENVIRONMENT}-weu"
PLAN="asp-bookstore-${ENVIRONMENT}-weu"
APP="app-bookstore-${ENVIRONMENT}-weu"
IDENTITY="id-bookstore-${ENVIRONMENT}-weu"
STORAGE="stbookstore${ENVIRONMENT}weu"
SQL_SERVER="sql-bookstore-${ENVIRONMENT}-weu"
az account set --subscription "$AZURE_SUBSCRIPTION_ID"
IDENTITY_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query id --output tsv)
CLIENT_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query clientId --output tsv)
az appservice plan create --name "$PLAN" --resource-group "$RG" --is-linux --sku "$SKU"
az webapp create --name "$APP" --resource-group "$RG" --plan "$PLAN" \
--runtime "DOTNETCORE:10.0" --assign-identity "$IDENTITY_ID" \
--https-only true --basic-auth Disabled
az webapp config set --name "$APP" --resource-group "$RG" \
--always-on true --min-tls-version 1.2 --ftps-state Disabled \
--generic-configurations '{"healthCheckPath": "/health"}'
# App settings become environment variables; "__" is the section separator on Linux
az webapp config appsettings set --name "$APP" --resource-group "$RG" --settings \
ASPNETCORE_ENVIRONMENT=Production \
"Proxy__KnownNetworks=10.0.0.0/8;172.16.0.0/12;192.168.0.0/16" \
"BlobStorage__ServiceUri=https://${STORAGE}.blob.core.windows.net" \
"BlobStorage__ManagedIdentityClientId=${CLIENT_ID}"
# Passwordless: the identity signs in to Azure SQL (the database user is created in the managed databases post)
az webapp config connection-string set --name "$APP" --resource-group "$RG" \
--connection-string-type SQLAzure --settings \
"BookstoreDb=Server=tcp:${SQL_SERVER}.database.windows.net,1433;Database=BookstoreDb;Authentication=Active Directory Managed Identity;User Id=${CLIENT_ID};Encrypt=True"
dotnet publish src/Bookstore.Api --configuration Release --output publish
(cd publish && zip -qr ../bookstore-api.zip .)
az webapp deploy --name "$APP" --resource-group "$RG" --src-path bookstore-api.zip --type zip
The quickstart on Learn uses the same --runtime "DOTNETCORE:10.0" format. Settings to note in the script: --https-only true redirects HTTP requests to HTTPS; --basic-auth Disabled turns off deployment with a username and password for FTP and SCM, so deployments can only be done with Entra ID; --assign-identity attaches the user-assigned identity we created in post 3.
App settings reach the application as environment variables. On Linux the section separator is __: BlobStorage__ServiceUri overrides BlobStorage:ServiceUri from appsettings.json. Connection strings are kept in a separate section and arrive with a prefix that depends on their type: BookstoreDb of type SQLAzure shows up in the application as SQLAZURECONNSTR_BookstoreDb. ASP.NET Core's environment variable provider recognizes these prefixes and puts the value into ConnectionStrings:BookstoreDb. You can see this without Azure; we started the API without ConnectionStrings__BookstoreDb, using only the variable App Service would provide:
$ export ASPNETCORE_ENVIRONMENT=Production
$ export SQLAZURECONNSTR_BookstoreDb="Server=127.0.0.1,1433;Database=BookstoreDb;User Id=sa;Password=<password>;TrustServerCertificate=True"
$ export ASPNETCORE_URLS=http://127.0.0.1:5191
$ dotnet src/Bookstore.Api/bin/Release/net10.0/Bookstore.Api.dll &
$ curl -s -w " %{http_code}\n" http://127.0.0.1:5191/health
Healthy 200
The connection string in the script has no password: Authentication=Active Directory Managed Identity and the identity's client ID tell SqlClient to get the token from the managed identity. For this to work, a user for that identity has to be created in the database; that step, together with connection resilience, is in post 8.
Health Check and Always On
App Service's health check calls the path you give it on every instance once a minute. According to Learn, if an instance doesn't answer with a status between 200 and 299 in 10 attempts (configurable down to 2), it is removed from the load balancer, and if it stays unhealthy for an hour it is replaced. To use this properly we added two endpoints to Bookstore.Api:
using System.Net;
using Bookstore.Data;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.HttpOverrides;
namespace Bookstore.Hosting;
public static class CloudHostingExtensions
{
// App Service and Container Apps terminate TLS in front of the app and add X-Forwarded-* headers.
// Trust them only from the networks listed in Proxy:KnownNetworks (semicolon-separated CIDR ranges).
public static IServiceCollection AddCloudHosting(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
options.KnownProxies.Add(IPAddress.Loopback);
options.KnownProxies.Add(IPAddress.IPv6Loopback);
foreach (string cidr in (configuration["Proxy:KnownNetworks"] ?? "").Split(';', StringSplitOptions.RemoveEmptyEntries))
{
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse(cidr.Trim()));
}
});
services.AddHealthChecks()
.AddDbContextCheck<BookstoreContext>("database", tags: ["ready"]);
return services;
}
// /health/live: the process answers. /health: the API can also reach BookstoreDb.
public static WebApplication MapCloudHealthChecks(this WebApplication app)
{
app.MapHealthChecks("/health/live", new HealthCheckOptions { Predicate = _ => false });
app.MapHealthChecks("/health", new HealthCheckOptions { Predicate = check => check.Tags.Contains("ready") });
return app;
}
}
/health/live runs no checks; it only says the process is up. /health runs the checks tagged ready, that is, the database check added with AddDbContextCheck. In Program.cs we added AddCloudHosting, app.UseForwardedHeaders() (at the very start of the pipeline) and app.MapCloudHealthChecks(). Starting the container once with a reachable database and once with an address that doesn't exist:
$ curl -s -w " -> %{http_code} in %{time_total}s\n" http://localhost:18081/health/live
Healthy -> 200 in 0.001586s
$ curl -s -w " -> %{http_code} in %{time_total}s\n" http://localhost:18081/health
Healthy -> 200 in 0.466396s
# with the database unreachable
$ curl -s -w " -> %{http_code} in %{time_total}s\n" http://localhost:18081/health/live
Healthy -> 200 in 0.001606s
$ curl -s -w " -> %{http_code} in %{time_total}s\n" http://localhost:18081/health
Unhealthy -> 503 in 4.831722s
We use /health as the health check path, because Learn also recommends checking critical dependencies: an instance that can't reach the database has no business receiving traffic. Two rules on Learn limit the risk of this decision: at most half of the instances (50 percent by default) are taken out at the same time, and if all instances are unhealthy none is removed. So when the database is down for five minutes, App Service doesn't try to replace all instances. The answer to that part of the guess is no. Still, keep the database check's connection timeout short; the 4.8 seconds in the measurement are spent waiting for the connection timeout.
Always On keeps the application from being unloaded when it's idle; it should be on for health checks and scheduled work to behave properly. It isn't available on the Free tier.
Forwarded Headers: TLS Ends at the Platform
On App Service the HTTPS connection ends at the load balancer in front of the platform; the request reaches the application as plain HTTP, and the original scheme and client IP travel in the X-Forwarded-Proto and X-Forwarded-For headers. Container Apps' ingress does the same. If the application doesn't trust these headers, it thinks it's on HTTP. We recreated the guess locally: we sent a request with the headers App Service would send to the API in a container, while the application had no forwarded header configuration:
$ curl -s -i -X POST http://localhost:18081/api/carts \
-H "Host: app-bookstore-dev-weu.azurewebsites.net" \
-H "X-Forwarded-Proto: https" -H "X-Forwarded-For: 203.0.113.7" | grep Location
Location: http://app-bookstore-dev-weu.azurewebsites.net/api/carts/01a1db9e-8afb-739b-b001-f62a791434c4
The answer: http://. If the Angular application follows that address, it takes one more redirect because of --https-only; the server address in the OpenAPI document, every endpoint that builds absolute links, and rate limiting that looks at the client IP make the same mistake. The fix is the ForwardedHeadersOptions in CloudHostingExtensions: we trust the headers only when they come from the networks listed in Proxy:KnownNetworks. App Service's recommendation on Learn is the private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16); the script passes them through the Proxy__KnownNetworks app setting. The example in the docs uses the old KnownNetworks property; in .NET 10 it is replaced by KnownIPNetworks. We pinned the three cases of the rule with a test:
using System.Net;
using Bookstore.Hosting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Bookstore.Api.Tests;
public class ForwardedHeadersTests
{
[Theory]
[InlineData("10.0.0.5", "https")] // App Service front end on a private address
[InlineData("::ffff:10.0.0.5", "https")] // same address as Kestrel sees it on a dual-mode socket
[InlineData("203.0.113.9", "http")] // anyone else: the header is ignored
public async Task X_Forwarded_Proto_is_trusted_only_from_known_networks(string remoteIp, string expectedScheme)
{
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Proxy:KnownNetworks"] = "10.0.0.0/8;172.16.0.0/12;192.168.0.0/16" })
.Build();
ServiceCollection services = new();
services.AddLogging();
services.AddCloudHosting(configuration);
using ServiceProvider provider = services.BuildServiceProvider();
IApplicationBuilder app = new ApplicationBuilder(provider);
app.UseForwardedHeaders(provider.GetRequiredService<IOptions<ForwardedHeadersOptions>>().Value);
app.Run(_ => Task.CompletedTask);
RequestDelegate pipeline = app.Build();
DefaultHttpContext context = new();
context.Connection.RemoteIpAddress = IPAddress.Parse(remoteIp);
context.Request.Scheme = "http";
context.Request.Headers["X-Forwarded-Proto"] = "https";
context.Request.Headers["X-Forwarded-For"] = "198.51.100.20";
await pipeline(context);
Assert.Equal(expectedScheme, context.Request.Scheme);
}
}
$ dotnet test
Test run summary: Passed!
total: 18
failed: 0
succeeded: 18
skipped: 0
duration: 1s 971ms
The second case matters: when Kestrel listens on IPv4 and IPv6 together, it sees the client address as ::ffff:10.0.0.5, and the middleware in .NET 10 matches it against the IPv4 range. The post "Reverse Proxy Arkasında ASP.NET Core: Forwarded Headers, KnownIPNetworks ve Doğru Client IP" in the Web Server'lar ve Hosting series explains in detail why the ASPNETCORE_FORWARDEDHEADERS_ENABLED=true shortcut trusts everyone and how proxy chains are handled. On Container Apps the address range of the ingress depends on the environment's network; with Logging__LogLevel__Microsoft.AspNetCore.HttpOverrides=Debug you can see in the log the address the middleware rejects as "Unknown proxy" and adjust the list accordingly.
Deployment, Log Stream and Deployment Slots
The last three lines of the script are the code deployment: zip the dotnet publish output and send it with az webapp deploy --type zip. The same steps locally:
$ dotnet publish src/Bookstore.Api --configuration Release --output publish
Bookstore.Api -> src/Bookstore.Api/bin/Release/net10.0/Bookstore.Api.dll
Bookstore.Api -> publish/
$ (cd publish && zip -qr ../bookstore-api.zip .)
Because it's published framework-dependent, the zip is around 28 MB; the runtime is already on App Service. To follow the application's console output live, you first turn on container logging (az webapp log config --docker-container-logging filesystem) and then run az webapp log tail. If you see a 404 for /robots933456.txt in the logs, don't worry: App Service probes whether the container serves requests with this dummy path.
In this post we deploy by hand; in a real team this step belongs to a pipeline. Post 10 of the GitHub Actions series covers passwordless deployment to the same App Service with OIDC, and the Azure DevOps Pipelines series covers the Azure DevOps side. For zero-downtime releases, deployment slots (on Standard and higher plans) let you warm up the new version in a separate slot and then swap it with production; post 4 of the Deployment Stratejileri series builds blue-green deployment with App Service slots.
Container Apps: Environments, Revisions, Ingress and Scale Rules
To put the same API on Container Apps as a container, the image has to be in a registry. Pushing the image we produced with dotnet publish /t:PublishContainer in post 4 to GitHub Container Registry (GHCR) is the pipeline's job; here we use an image that is already in the registry:
#!/usr/bin/env bash
# Bookstore.Api on Container Apps: the same API as a container image from GitHub Container Registry.
set -euo pipefail
: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
ENVIRONMENT="${1:?Usage: 05-container-apps.sh <dev|staging|prod> <image>}"
IMAGE="${2:?Usage: 05-container-apps.sh <dev|staging|prod> ghcr.io/<owner>/bookstore-api:<tag>}"
RG="rg-bookstore-${ENVIRONMENT}-weu"
CAE="cae-bookstore-${ENVIRONMENT}-weu"
APP="ca-bookstore-${ENVIRONMENT}-weu"
IDENTITY="id-bookstore-${ENVIRONMENT}-weu"
STORAGE="stbookstore${ENVIRONMENT}weu"
SQL_SERVER="sql-bookstore-${ENVIRONMENT}-weu"
az account set --subscription "$AZURE_SUBSCRIPTION_ID"
IDENTITY_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query id --output tsv)
CLIENT_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query clientId --output tsv)
az containerapp env create --name "$CAE" --resource-group "$RG" --location westeurope
az containerapp create --name "$APP" --resource-group "$RG" --environment "$CAE" \
--image "$IMAGE" --target-port 8080 --ingress external \
--cpu 0.5 --memory 1.0Gi --min-replicas 0 --max-replicas 5 \
--scale-rule-name http --scale-rule-type http --scale-rule-http-concurrency 50 \
--user-assigned "$IDENTITY_ID" --revisions-mode multiple \
--env-vars ASPNETCORE_ENVIRONMENT=Production \
"Proxy__KnownNetworks=10.0.0.0/8;172.16.0.0/12;192.168.0.0/16" \
"BlobStorage__ServiceUri=https://${STORAGE}.blob.core.windows.net" \
"BlobStorage__ManagedIdentityClientId=${CLIENT_ID}" \
"ConnectionStrings__BookstoreDb=Server=tcp:${SQL_SERVER}.database.windows.net,1433;Database=BookstoreDb;Authentication=Active Directory Managed Identity;User Id=${CLIENT_ID};Encrypt=True"
az containerapp show --name "$APP" --resource-group "$RG" --query properties.configuration.ingress.fqdn --output tsv
A Container Apps environment is the boundary of container apps that share the same network and log destination. --target-port 8080 is the default port of the official ASP.NET Core images since .NET 8. --min-replicas 0 and --scale-rule-http-concurrency 50 are a dev setting that accepts the cold start we measured in post 4; in prod the minimum becomes 1. With --revisions-mode multiple every deployment opens a new revision and you split traffic by hand: az containerapp ingress traffic set --revision-weight <old>=90 <new>=10. In single mode traffic moves entirely to the new revision once it's ready, and the old one is deactivated.
If the image on GHCR is private, you have to give Container Apps a username and token, because GHCR doesn't know Azure managed identities. If you don't want to store a secret, the alternative is ACR, Azure Container Registry: the container app's managed identity gets the "AcrPull" role on the registry and pulls without a password through --registry-identity. Learn's scaling page also points out that .NET applications running on several replicas need to share their ASP.NET Core Data Protection keys; Bookstore.Api doesn't use cookies or antiforgery, so it isn't affected today, but the day a Razor UI is added this comes up.
Angular: Static Web Apps and SPA Fallback
A built Angular application is just a few static files; running them on a web server is unnecessary. On Azure there are two paths: Static Web Apps, or a CDN or Front Door in front of Blob Storage's static website feature (the second is a topic for the Domain, DNS ve CDN series). According to Learn, Static Web Apps has two plans today: Free for personal projects and Standard for production. The Dedicated plan was retired on 31 October 2025. Standard is needed for an SLA, more custom domains, private endpoints and linking your own API as a linked backend.
The real problem with an SPA is routing: /books/9780451524935 is an address produced by the Angular router in the browser, and there is no such file on the server. When the user refreshes the page and the server doesn't return index.html, they get a 404. On Static Web Apps staticwebapp.config.json solves this; we put the file into Angular's public/ folder so it is copied to the root of the build output:
{
"navigationFallback": {
"rewrite": "/index.html",
"exclude": [
"/api/*",
"/*.{js,css,ico,png,jpg,svg,webp,woff2,map,json}"
]
},
"routes": [
{
"route": "/index.html",
"headers": {
"Cache-Control": "no-cache"
}
}
],
"globalHeaders": {
"X-Content-Type-Options": "nosniff"
}
}
navigationFallback returns /index.html for every request without a match; exclude protects two things: /api/* requests go to the API, and a missing JS or CSS file stays a 404 instead of turning into HTML and causing a confusing error in the browser. We tried this with the Static Web Apps CLI emulator; it serves the built Angular application and forwards /api requests to Bookstore.Api running locally:
$ npx ng build
$ swa start dist/bookstore-web/browser --api-devserver-url http://127.0.0.1:5191 --port 4291
/ 200 text/html
/books/9780451524935 200 text/html
/cart 200 text/html
/main-missing.js 404 text/html; charset=UTF-8
/api/books?pageSize=1 200 application/json; charset=utf-8
/api/nothing 404 application/problem+json
The answer to the second part of the guess: with the fallback configured, 200 and the Angular application. Note the emulator's warning too: the CLI says on its first lines that the environment doesn't match the cloud exactly. In production, to make /api work from the same origin, we link App Service as a linked backend on the Standard plan; on the Free plan Angular would have to call the API by absolute address and the API would need CORS:
#!/usr/bin/env bash
# bookstore-web on Static Web Apps (Standard plan), with /api linked to the App Service API.
set -euo pipefail
: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
ENVIRONMENT="${1:?Usage: 05-static-web-app.sh <dev|staging|prod>}"
RG="rg-bookstore-${ENVIRONMENT}-weu"
SWA="stapp-bookstore-${ENVIRONMENT}-weu"
API="app-bookstore-${ENVIRONMENT}-weu"
az account set --subscription "$AZURE_SUBSCRIPTION_ID"
az staticwebapp create --name "$SWA" --resource-group "$RG" --location westeurope --sku Standard
# Linked backend: requests to /api/* go to the API, same origin, no CORS
API_ID=$(az webapp show --name "$API" --resource-group "$RG" --query id --output tsv)
az staticwebapp backends link --name "$SWA" --resource-group "$RG" \
--backend-resource-id "$API_ID" --backend-region westeurope
(cd web/bookstore-web && npm ci && npx ng build)
TOKEN=$(az staticwebapp secrets list --name "$SWA" --resource-group "$RG" --query properties.apiKey --output tsv)
npx @azure/static-web-apps-cli@2.1.0 deploy web/bookstore-web/dist/bookstore-web/browser \
--deployment-token "$TOKEN" --env production
Comparing the Two Paths and the AWS Equivalents
The last column gives the AWS equivalents of each choice.
| Topic | App Service (code) | Container Apps (container) | AWS equivalent |
|---|---|---|---|
| What you hand over | Zip, runtime on the platform | Image, runtime in the image | A zip to Beanstalk or an image to ECS Express Mode |
| Billing | Plan instance hours | vCPU and GiB seconds, request count | EC2 hours or Fargate vCPU and GB hours |
| Scaling to zero | No | Yes, minimum replicas 0 | No on Fargate, yes on Lambda |
| Zero-downtime releases | Deployment slots, swap | Revisions and traffic splitting | Beanstalk blue/green, ECS deployments |
| Runtime patches | Done by the platform | You rebuild the image | Beanstalk platform updates, rebuilding the image |
| Angular | Static Web Apps | Static Web Apps | S3 and CloudFront or Amplify Hosting |
For Bookstore today the recommendation is App Service: for a single API the plan model is simple, slots are built in and the platform patches the runtime. The day institutional ordering is split into a separate service or campaign load swings a lot, moving to Container Apps is easy, because the API can already be produced as an image.
In Production
Don't turn on the health check only after deploying. Learn says that changing the health check setting restarts the application; set it during the initial setup and leave it alone.
Deploy the container image with a fixed tag or digest. A revision using latest can end up on different code when the image is pulled again; when you want to roll back, you won't know which version was running.
Don't leave environment-specific settings in the build that goes to Static Web Apps. An Angular build is static files; a prod API address or key gets baked into the build and anyone can read it. Calling /api from the same origin through a linked backend is cleaner for this reason as well.
Common Pitfalls
Not checking the port the application listens on. ASP.NET Core images listen on 8080; --target-port on Container Apps, and WEBSITES_PORT on App Service if you use a custom container, must match it, or the platform can't see the container as running.
Ignoring forwarded headers or trusting everyone. The first produces links with the wrong scheme, the second makes it possible to spoof the client IP.
Leaving scale-to-zero as the default in prod. The default minimum replica count on Container Apps is 0; if you don't accept a cold start on the first request, deliberately make it 1.
Not excluding /api and static files from the SPA fallback. Returning index.html for a missing JS file leads to an error in the browser such as "Unexpected token <" that hides the real problem.
A Small Experiment
★☆☆ Remove the exclude line from staticwebapp.config.json and run the emulator again; how does the response to /main-missing.js change?
★★☆ Add a case to ForwardedHeadersTests where the X-Forwarded-For header carries two addresses (the client and an intermediate proxy). With the default ForwardLimit, which address does RemoteIpAddress become?
★★★ Change 05-container-apps.sh to pull the image from ACR with a managed identity: create the registry, grant the identity the AcrPull role, use --registry-identity. Write down which step is on the control plane and which on the data plane.
In the next post we add the first serverless piece to Bookstore: the Azure Functions isolated worker model, the Flex Consumption plan and an Event Grid triggered function that produces a thumbnail when a new cover image is uploaded.