Skip to content
Kumar Chandrachooda
Azure & DevOps

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.

By Kumar Chandrachooda 14 Jul 2025 3 min read
Commit, build, ship: the whole pipeline in three stages

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/workflows that builds and ships on every push to main.

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/login with 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.

Filed under Azure & DevOps