Deploying ASP.NET Core to Azure App Service with GitHub Actions
A practical walkthrough of the pipeline that ships this very website: from push to production on Azure App Service, with secrets kept out of the repository and zero manual steps.
Every project deserves a deployment story that is boring. The exciting kind (remote desktop sessions, dragging zip files, editing web.config on the server) is exactly what we are trying to retire. This post walks through the GitHub Actions workflow that deploys an ASP.NET Core MVC application to Azure App Service, which happens to be the same setup powering the site you are reading.
The moving parts
The pipeline only needs three ingredients:
- An App Service instance (Windows or Linux plan both work).
- A publish profile stored as an encrypted GitHub secret.
- A workflow file in
.github/workflowsthat builds and ships on every push tomain.
A publish profile is the quickest way to authenticate, but for larger teams prefer an Azure AD service principal with azure/login; it supports scoped RBAC and rotates cleanly.
The workflow
The complete workflow fits in one file. The build job compiles, tests and publishes the app, then hands the artifact to a deploy job:
name: Build and deploy to Azure Web App
on:
push:
branches:
- main
env:
AZURE_WEBAPP_NAME: kumarchandrachooda2
CONFIGURATION: Release
DOTNET_CORE_VERSION: 6.0.x
WORKING_DIRECTORY: Kumar.Chandrachooda.Resume
jobs:
build:
runs-on: windows-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET
uses: actions/setup-dotnet@v1
with:
dotnet-version: ${{ env.DOTNET_CORE_VERSION }}
- name: Restore
run: dotnet restore "${{ env.WORKING_DIRECTORY }}"
- name: Build
run: dotnet build "${{ env.WORKING_DIRECTORY }}" --configuration ${{ env.CONFIGURATION }} --no-restore
- name: Publish
run: dotnet publish "${{ env.WORKING_DIRECTORY }}" --configuration ${{ env.CONFIGURATION }} --no-build --output published
Two details carry most of the weight here. --no-restore and --no-build stop the pipeline doing the same work three times, and the artifact produced by dotnet publish is the only thing the deploy job ever touches, so the deploy step cannot accidentally compile different code than what was tested.
Provisioning with the Azure CLI
Everything on the Azure side can be created from a terminal. PowerShell version, because that is where I live:
$rg = 'rg-resume-prod'
$app = 'kumarchandrachooda2'
az group create --name $rg --location uksouth
az appservice plan create --name "$app-plan" --resource-group $rg --sku B1
az webapp create --name $app --resource-group $rg --plan "$app-plan" --runtime 'dotnet:6'
# Download the publish profile for the GitHub secret
az webapp deployment list-publishing-profiles --name $app --resource-group $rg --xml
Paste the XML output into a repository secret (Settings, then Secrets and variables, then Actions) and reference it from the deploy job:
deploy:
runs-on: windows-latest
needs: build
steps:
- name: Deploy to Azure WebApp
uses: azure/webapps-deploy@v2
with:
app-name: ${{ env.AZURE_WEBAPP_NAME }}
publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }}
package: published
Never commit a publish profile to the repository, even in a private repo. It contains live deployment credentials, so treat it like a password, rotate it from the portal if it ever leaks, and keep it in GitHub Secrets where it is encrypted at rest.
Choosing a plan tier
For a personal site or a low-traffic API, the smaller tiers are perfectly serviceable:
| Tier | Cores / RAM | Custom domain + TLS | Slots | Good for |
|---|---|---|---|---|
| F1 Free | Shared / 1 GB | No | No | Experiments |
| B1 Basic | 1 / 1.75 GB | Yes | No | Personal sites |
| S1 Standard | 1 / 1.75 GB | Yes | 5 | Small production apps |
| P1v3 Premium | 2 / 8 GB | Yes | 20 | Real workloads |
The jump that matters is Standard: deployment slots unlock blue-green deploys, so the workflow can deploy to a staging slot, warm it up, and swap, so production never serves a cold start.
Health checks before the swap
If you do use slots, add a smoke test between deploy and swap. A tiny endpoint in the app:
app.MapGet("/healthz", (IWebHostEnvironment env) => Results.Ok(new
{
status = "healthy",
environment = env.EnvironmentName,
version = typeof(Program).Assembly.GetName().Version?.ToString()
}));
And a probe in the workflow that refuses to swap a broken build:
for i in $(seq 1 10); do
status=$(curl -s -o /dev/null -w '%{http_code}' "https://$APP-staging.azurewebsites.net/healthz")
[ "$status" = "200" ] && exit 0
sleep 6
done
echo "staging never became healthy" && exit 1
Wrapping up
The whole arrangement is maybe forty lines of YAML, yet it removes every manual step between git push and production. Things I would add as the project grows:
- Restore, build, publish as separate cached steps
- Publish profile in encrypted secrets
- Deployment slots with an automated swap
-
azure/loginwith a federated service principal - Bicep templates so the infrastructure is in the repo too
Boring deployments are a feature. Ship the robot, keep the weekend.