Azure App Service is the path of least resistance for hosting ASP.NET Core: no VMs to patch, no Kubernetes to learn, and first-class .NET support. But "least resistance" has a trap — it is easy to right-click-publish something that works today and becomes unmaintainable next month. In this part we set up App Service the way we would want to inherit it: scripted provisioning, CI/CD from day one, configuration out of the codebase, and zero-downtime deploys.
Provisioning with the az CLI
We could click through the portal, but a script is repeatable, reviewable, and doubles as documentation. An App Service deployment has two parts: a plan (the compute we pay for) and a web app (our application, running on that plan). Several apps can share one plan.
RG=rg-myapp-prod
LOCATION=westeurope
PLAN=plan-myapp-prod
APP=app-myapp-prod # must be globally unique: becomes app-myapp-prod.azurewebsites.net
az group create --name $RG --location $LOCATION
az appservice plan create \
--name $PLAN --resource-group $RG \
--sku B1 --is-linux
az webapp create \
--name $APP --resource-group $RG --plan $PLAN \
--runtime "DOTNETCORE:10.0"
Two deliberate choices here. Linux, because it is cheaper than Windows for the same tier and ASP.NET Core has been fully at home on it for years. And B1 (Basic), because the Free tier lacks Always On and custom-domain SSL, while B1 is a realistic floor for anything a human will actually visit. Check available runtimes anytime with az webapp list-runtimes --os-type linux.
| Tier | Approx. cost | Always On | Slots | Good for |
|---|---|---|---|---|
| F1 Free | $0 | No | 0 | Throwaway experiments |
| B1 Basic | ~$13/mo | Yes | 0 | Small production apps, dev/test |
| S1 Standard | ~$70/mo | Yes | 5 | Production with slot swaps |
| P0v3 Premium | ~$60/mo | Yes | 20 | Production; better hardware than S1 |
Note the oddity: P0v3 often costs less than S1 with better hardware. Always compare current prices before defaulting to Standard. Slots — which we want for zero-downtime deploys later — start at Standard/Premium.
How do I deploy an ASP.NET Core application to Azure App Service?
The fastest way is az webapp up from the project folder, which creates the plan and web app if needed, zips the build output, and deploys it in one command. For anything beyond experiments, deploy from CI instead: a GitHub Actions workflow that runs dotnet publish and pushes the package with the azure/webapps-deploy action, authenticated via OpenID Connect so no secrets are stored.
The quick way and the right way
For a first smoke test, az webapp up is genuinely great:
cd src/MyApp.Web
az webapp up --name $APP --resource-group $RG --runtime "DOTNETCORE:10.0"
It infers everything, deploys, and prints the URL. Use it to prove the app runs on Azure — then stop using it, because deploys should not depend on whichever laptop last ran the command.
The right way is GitHub Actions with OIDC federation (no publish profile, no stored secret). One-time setup: create an app registration with a federated credential for our repo (az ad app federated-credential create), grant it the Website Contributor role on the web app, and add the tenant/subscription/client IDs as repository variables. Then the workflow:
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- run: dotnet publish src/MyApp.Web -c Release -o ./publish
- uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
- uses: azure/webapps-deploy@v3
with:
app-name: app-myapp-prod
package: ./publish
The id-token: write permission is what enables OIDC: GitHub mints a short-lived token, Entra ID trusts it because of the federated credential, and nothing long-lived exists to leak. The complete setup script and workflow live in the 02-appservice folder of the companion repo.
Configuration: appsettings, App Service settings, Key Vault
ASP.NET Core's configuration system layers providers, and App Service slots neatly into it. App Service application settings are injected as environment variables, and the environment-variable provider overrides appsettings.json. Nested keys use __ as the separator:
az webapp config appsettings set --name $APP --resource-group $RG --settings \
ASPNETCORE_ENVIRONMENT=Production \
"Smtp__Host=smtp.example.com" \
"Smtp__Port=587"
In code, builder.Configuration["Smtp:Host"] resolves exactly as if it came from JSON. So the division of labor is: non-secret defaults in appsettings.json (committed), per-environment values in App Service settings, and secrets in Key Vault — referenced from App Service settings so the app never touches Key Vault APIs directly:
az webapp config appsettings set --name $APP --resource-group $RG --settings \
"ConnectionStrings__Default=@Microsoft.KeyVault(SecretUri=https://kv-myapp.vault.azure.net/secrets/sql-connection/)"
For that reference to resolve, the web app needs a managed identity (az webapp identity assign) with the Key Vault Secrets User role on the vault — the same no-secrets pattern we established in part 1. From the app's perspective it is just another configuration key.
Zero-downtime deploys with slots
On Standard or Premium tiers, deployment slots give us a staging copy of the app on the same plan:
az webapp deployment slot create --name $APP --resource-group $RG --slot staging
We deploy to the staging slot (add slot-name: staging to the webapps-deploy step), verify at app-myapp-prod-staging.azurewebsites.net, then swap:
az webapp deployment slot swap --name $APP --resource-group $RG --slot staging
The swap warms up the staging instance, waits for it to respond, then switches routing — visitors never hit a cold or half-deployed app, and if something is wrong, swapping back is the same command. Two details matter: settings are swapped along with the code unless marked "slot setting" (mark ASPNETCORE_ENVIRONMENT and anything environment-specific as slot settings), and configure a health-check path so the swap has something meaningful to probe.
Logs and diagnostics when things go wrong
The first deploy that fails will fail silently — a 500.30 page with no detail, by design. Turn on log capture and tail it live:
az webapp log config --name $APP --resource-group $RG \
--application-logging filesystem --level information
az webapp log tail --name $APP --resource-group $RG
log tail streams both the container output and our app's ILogger output, which is usually enough to spot a missing configuration key or a failed database connection at startup. For anything longer-term, wire up Application Insights (az monitor app-insights component create plus the Microsoft.ApplicationInsights.AspNetCore package) — filesystem logs rotate away quickly and are for live debugging, not history.
Gotchas that bite everyone once
Port binding. On Linux App Service the platform tells our app which port to use via the PORT environment variable and expects it to listen there (8080 by default). Kestrel in .NET 8+ respects this automatically; if we override UseUrls or hardcode ASPNETCORE_URLS, the container fails its health ping and we get "Container didn't respond to HTTP pings" in the logs. Don't fight the default.
ASPNETCORE_ENVIRONMENT. It defaults to Production on App Service, which is correct — but means developer exception pages are off and appsettings.Development.json is ignored. Set it explicitly per slot and mark it as a slot setting, or a swap will quietly move "Staging" into production.
Always On. On Basic and above, enable it (az webapp config set --always-on true). Without it, the app is unloaded after ~20 minutes idle and the next visitor pays several seconds of cold start. Free tier does not support it, which is the real reason Free feels slow.
Zip deploy runs from a read-only package. With the now-default WEBSITE_RUN_FROM_PACKAGE=1, the app's directory is read-only. Code that writes next to the binaries (data protection keys, SQLite files, uploads) must be pointed at /home or, better, external storage.
What's next
Our app is deployed, configured, and observable — but it is stateless, and real applications need a database. In part 3 we provision Azure SQL in its serverless tier, connect EF Core with retry-on-failure enabled, and work through the firewall and authentication gotchas that trip up every first Azure SQL deployment.
Comments (0)
No comments yet — be the first to share your thoughts.