Skip to main content
SEMastery
DevOpsbeginner

Streamlining .NET 9 Deployment With GitHub Actions and Azure

A friendly, step-by-step guide to deploying a .NET 9 app to Azure App Service using GitHub Actions, with secure OIDC login, build, test, and release.

13 min readUpdated October 10, 2025

Imagine you cook a lovely meal at home and you want your grandmother across the city to taste it. You could carry the hot dish yourself on a bus every single time. That works, but it is slow and tiring, and one day you will forget the salt or drop the dish.

Now imagine a friendly delivery person who picks up your meal the moment it is ready, checks that it is packed well, and drops it at your grandmother's door, every time, the same careful way. You just cook. The delivery happens on its own.

That delivery person is what we are building today. Your "meal" is a .NET 9 web app. Your "grandmother's door" is Azure. And the careful delivery person is GitHub Actions. When you finish your code and push it, GitHub Actions builds it, tests it, and ships it to Azure for you. No bus rides. No forgotten salt.

This guide is for beginners. We will go slow, use simple words, and build the whole pipeline step by step.

What we are building

We want this: you write code, push it to GitHub, and your live website updates by itself. People call this CI/CD.

  • CI means Continuous Integration. Every time you push code, a robot builds it and runs your tests to check nothing is broken.
  • CD means Continuous Deployment. If the build is happy, the same robot sends your app to Azure so real users can see it.

Here is the big picture.

The journey of your code from your laptop to real users

Each box is one small job. If any box fails, the journey stops, and Azure keeps showing the old, working version. That safety net is one of the best parts.

The pieces you need

Before we write anything, let us meet the three tools.

ToolWhat it isWhy we use it
.NET 9The kit you use to build the appIt compiles your C# code into a running website
GitHub ActionsA robot that runs steps for youIt builds, tests, and ships your code automatically
Azure App ServiceA home for web apps in the cloudIt hosts your site so anyone can visit the URL

You also need a GitHub account and an Azure account. Both have free options that are plenty for learning.

Step 1: A tiny .NET 9 app

Let us start with the smallest possible web app so the pipeline is the star, not the code. Open a terminal and create a new minimal API project.

// Program.cs — a tiny .NET 9 minimal API
var builder = WebApplication.CreateBuilder(args);
 
var app = builder.Build();
 
// A simple health check so we can prove the app is alive
app.MapGet("/", () => "Hello from .NET 9 on Azure!");
 
app.MapGet("/health", () => Results.Ok(new { status = "healthy" }));
 
app.Run();

This app does two things. The home page says hello, and the /health page returns a small JSON object. The health page is handy later, because Azure and your team can poke it to ask, "Are you awake?"

Run it locally first to be sure it works.

// In your terminal, not in C#:
//   dotnet run
// Then open http://localhost:5000 in your browser.
// You should see: Hello from .NET 9 on Azure!

If you see the hello message, your meal is cooked. Now we set up delivery.

Step 2: How GitHub talks to Azure (safely)

The robot needs permission to put files into your Azure app. There are two common ways to give that permission.

MethodHow it worksGood for
Publish profileA file with a hidden password is stored as a GitHub secretQuick first tries and demos
OIDC (OpenID Connect)GitHub proves who it is with a short-lived token; no stored passwordReal projects and teams

The publish profile is the easiest to start with, so we will use it first. Then we will show the safer OIDC way, which Microsoft and GitHub both recommend.

Two ways to let GitHub deploy to Azure

Publish profile
OIDC token
Azure trusts it
Deploy

Steps

1

Publish profile

Stored secret password

2

OIDC token

Short-lived, no stored password

3

Azure trusts it

Checks the proof

4

Deploy

Files go live

Start simple, then move to the safer option

Getting a publish profile

  1. In the Azure Portal, open your App Service.
  2. Click Get publish profile to download a small file.
  3. Open the file, copy everything inside.
  4. In your GitHub repo, go to Settings → Secrets and variables → Actions.
  5. Make a new secret called AZURE_WEBAPP_PUBLISH_PROFILE and paste the file content.

A secret is a locked box. GitHub Actions can use what is inside, but nobody can read it back out, not even you. That keeps the password hidden.

Step 3: Your first workflow file

GitHub Actions reads instructions from a special file. It lives at .github/workflows/deploy.yml in your repo. The folder name and the .yml ending matter, so type them exactly.

Here is a full, working workflow using the publish profile.

// File: .github/workflows/deploy.yml
// (This is YAML, shown here so you can copy it.)
//
// name: Deploy .NET 9 to Azure
//
// on:
//   push:
//     branches: [ "main" ]
//
// env:
//   AZURE_WEBAPP_NAME: my-dotnet9-app
//   DOTNET_VERSION: '9.0.x'
//
// jobs:
//   build-and-deploy:
//     runs-on: ubuntu-latest
//     steps:
//       - uses: actions/checkout@v4
//
//       - name: Set up .NET
//         uses: actions/setup-dotnet@v4
//         with:
//           dotnet-version: ${{ env.DOTNET_VERSION }}
//
//       - name: Restore
//         run: dotnet restore
//
//       - name: Build
//         run: dotnet build --configuration Release --no-restore
//
//       - name: Test
//         run: dotnet test --no-build --configuration Release
//
//       - name: Publish
//         run: dotnet publish -c Release -o ./publish
//
//       - name: Deploy to Azure
//         uses: azure/webapps-deploy@v3
//         with:
//           app-name: ${{ env.AZURE_WEBAPP_NAME }}
//           publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
//           package: ./publish

Let us read this like a recipe, line by line in plain words.

  • on: push: branches: [ "main" ] means "run this whenever someone pushes to the main branch."
  • runs-on: ubuntu-latest gives the robot a fresh Linux computer to work on.
  • actions/checkout@v4 copies your code onto that computer.
  • setup-dotnet installs .NET 9 so the robot can build.
  • restore, build, test, and publish are the normal .NET commands you already know.
  • The last step takes the published files and sends them to Azure.

Notice we set DOTNET_VERSION to 9.0.x. The x means "any small update of version 9," so you get the latest patch automatically.

Step 4: What actually happens on each push

When you push to main, GitHub spins up the robot computer and runs your steps in order. If a step fails, the ones after it are skipped, and you get a red mark. If everything is green, your app is live.

The order of jobs inside one workflow run

This whole run usually takes a couple of minutes. The first time is a little slower because the robot downloads the .NET SDK and your NuGet packages. Later runs are faster.

Step 5: The safer login with OIDC

The publish profile works, but it stores a password. If that secret ever leaks, someone could deploy to your app. OIDC fixes this. With OIDC, GitHub does not store a password at all. Instead, for each run, GitHub asks Azure for a short-lived token, like a one-time entry pass that expires in minutes.

Here is how the trust is set up.

How OIDC trust is built

Create app in Entra
Add federated credential
Grant role on App Service
Use azure/login

Steps

1

Create app in Entra

An identity for GitHub

2

Add federated credential

Trust your repo and branch

3

Grant role

Allow deploy to the app

4

Use azure/login

Token-based sign in

No stored password ever leaves your control

To make OIDC work you create three GitHub secrets that are not passwords. They are just ID numbers that are safe to keep:

Secret nameWhat it holds
AZURE_CLIENT_IDThe ID of the identity GitHub uses
AZURE_TENANT_IDThe ID of your Azure directory
AZURE_SUBSCRIPTION_IDThe ID of your Azure subscription

Then your workflow logs in like this. The key part is permissions: id-token: write, which lets GitHub create that short-lived token.

// OIDC login steps (YAML) to replace the publish-profile deploy:
//
// permissions:
//   id-token: write
//   contents: read
//
// steps:
//   - name: Log in to Azure
//     uses: azure/login@v2
//     with:
//       client-id: ${{ secrets.AZURE_CLIENT_ID }}
//       tenant-id: ${{ secrets.AZURE_TENANT_ID }}
//       subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
//
//   - name: Deploy to Azure
//     uses: azure/webapps-deploy@v3
//     with:
//       app-name: my-dotnet9-app
//       package: ./publish

See the difference? There is no publish-profile line with a password. The login step proves who GitHub is, and Azure hands over access only for that single run. When the run ends, the pass is gone. This is why teams prefer OIDC.

A quick word on routes and curly braces while we are here. If you add an endpoint like GET /{id}, always wrap it in backticks when you write about it in notes or docs, so tools do not get confused by the braces.

Step 6: Splitting into build and deploy jobs

As your project grows, it is tidy to split the work into two jobs: one builds and tests, and the other deploys only if the first passed. This keeps things clear and lets you add an approval step before going live.

Two jobs, where deploy waits for build to pass

The Approve step is optional but lovely for real apps. It lets a human click "yes, go ahead" before the app changes for users. In GitHub, you set this up with environments and a required reviewer. Think of it like a teacher checking your homework before it goes on the class noticeboard.

Common mistakes and easy fixes

Beginners hit the same small bumps. Here are the usual ones and how to get past them.

ProblemLikely causeEasy fix
Workflow does not run at allFile is in the wrong placePut it in .github/workflows/ with a .yml ending
Build works locally but fails in ActionsWrong .NET versionSet dotnet-version to 9.0.x
Deploy says "unauthorized"Bad or missing secretRe-add the publish profile or check OIDC secrets
App name not foundTypo in app nameMatch app-name to your real Azure app exactly
Tests fail in Actions onlyA file or env value is missingCommit test files and set needed variables

If a run is red, click it in the Actions tab. GitHub shows each step with a tick or a cross, and the cross opens the exact error message. Read the last few red lines first; they almost always tell you the real cause.

A note on packages and licenses

When you add libraries to your app, check their license before you rely on them at work. For example, some popular .NET libraries like MediatR and MassTransit moved to commercial licensing, which means larger teams may need to pay. For a small learning app you are usually fine, but it is a good habit to read the license so there are no surprises later.

Keeping deployments healthy

Once your pipeline works, add small touches that make life easier:

  • Use the /health endpoint we built so Azure can check your app is alive.
  • Turn on caching for NuGet packages to make runs faster.
  • Deploy to a staging slot first, test it, then swap it into production with zero downtime.

A staging slot is like a dress rehearsal. Your new version runs on a private URL, you check it, and only then does it become the real site. If something is wrong, you swap back in seconds.

Staging slot keeps users safe during a release

This pattern means your users almost never see a broken page. They see the old version until the new one is proven safe.

Putting it all together

Let us recap the full flow one more time, the way it runs in real life. You finish a feature on your laptop. You run dotnet test to be sure it works. You push to the main branch. GitHub Actions wakes up, builds your .NET 9 app on a clean Linux machine, runs the tests, and publishes the output. It logs in to Azure with a short-lived OIDC token, sends the files, and your site updates. A few minutes later, your grandmother, sorry, your users, see the new version. You did not carry a single hot dish on the bus.

The beauty is that this happens the same careful way every single time. No forgotten steps. No "it worked on my machine." Just push, and ship.

Quick recap

  • CI/CD means a robot builds, tests, and ships your code automatically when you push.
  • A .NET 9 minimal API is enough to learn the whole pipeline; keep the app tiny at first.
  • GitHub Actions reads a workflow file at .github/workflows/deploy.yml.
  • A publish profile is the quickest way to let GitHub deploy to Azure.
  • OIDC is the safer, recommended way because it uses short-lived tokens, not stored passwords.
  • Split work into build and deploy jobs, and add a human approval for real apps.
  • Use a staging slot to test before swapping into production with no downtime.
  • Read the license of any library, since some like MediatR and MassTransit are now commercial.
  • When a run fails, open the Actions tab and read the last red lines of the failing step.
  • Always wrap route placeholders like GET /{id} in backticks in your notes to avoid tool confusion.

References and further reading

Related Posts