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.
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.
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.
| Tool | What it is | Why we use it |
|---|---|---|
| .NET 9 | The kit you use to build the app | It compiles your C# code into a running website |
| GitHub Actions | A robot that runs steps for you | It builds, tests, and ships your code automatically |
| Azure App Service | A home for web apps in the cloud | It 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.
| Method | How it works | Good for |
|---|---|---|
| Publish profile | A file with a hidden password is stored as a GitHub secret | Quick first tries and demos |
| OIDC (OpenID Connect) | GitHub proves who it is with a short-lived token; no stored password | Real 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
Steps
Publish profile
Stored secret password
OIDC token
Short-lived, no stored password
Azure trusts it
Checks the proof
Deploy
Files go live
Getting a publish profile
- In the Azure Portal, open your App Service.
- Click Get publish profile to download a small file.
- Open the file, copy everything inside.
- In your GitHub repo, go to Settings → Secrets and variables → Actions.
- Make a new secret called
AZURE_WEBAPP_PUBLISH_PROFILEand 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: ./publishLet 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-latestgives the robot a fresh Linux computer to work on.actions/checkout@v4copies your code onto that computer.setup-dotnetinstalls .NET 9 so the robot can build.restore,build,test, andpublishare 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.
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
Steps
Create app in Entra
An identity for GitHub
Add federated credential
Trust your repo and branch
Grant role
Allow deploy to the app
Use azure/login
Token-based sign in
To make OIDC work you create three GitHub secrets that are not passwords. They are just ID numbers that are safe to keep:
| Secret name | What it holds |
|---|---|
AZURE_CLIENT_ID | The ID of the identity GitHub uses |
AZURE_TENANT_ID | The ID of your Azure directory |
AZURE_SUBSCRIPTION_ID | The 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: ./publishSee 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.
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.
| Problem | Likely cause | Easy fix |
|---|---|---|
| Workflow does not run at all | File is in the wrong place | Put it in .github/workflows/ with a .yml ending |
| Build works locally but fails in Actions | Wrong .NET version | Set dotnet-version to 9.0.x |
| Deploy says "unauthorized" | Bad or missing secret | Re-add the publish profile or check OIDC secrets |
| App name not found | Typo in app name | Match app-name to your real Azure app exactly |
| Tests fail in Actions only | A file or env value is missing | Commit 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
/healthendpoint 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.
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
- Deploy to Azure App Service by using GitHub Actions (Microsoft Learn)
- Authenticate to Azure from GitHub Actions by OpenID Connect (Microsoft Learn)
- Deploying .NET to Azure App Service (GitHub Docs)
- Configuring OpenID Connect in Azure (GitHub Docs)
- Azure Login action on GitHub Marketplace
Related Posts
Service Discovery in .NET Microservices with HashiCorp Consul
A beginner-friendly guide to service discovery in .NET microservices using HashiCorp Consul, with registration, health checks, and lookups explained simply.
How To Deploy a .NET App to Azure Using Neon Postgres and .NET Aspire
A beginner-friendly, step-by-step guide to deploying a .NET 10 web API to Azure Container Apps with a free Neon serverless Postgres database and .NET Aspire.
YARP vs Nginx: A Quick Performance Comparison for .NET
A simple, friendly look at YARP vs Nginx as a reverse proxy: how each one works, real benchmark numbers, tuning tips, and how to pick the right one.
Building Generative AI Apps With GitHub Models and .NET Aspire
A beginner-friendly guide to building generative AI apps in .NET using GitHub Models, .NET Aspire, and Microsoft.Extensions.AI with clean code examples.
.NET Aspire: A Game Changer for Cloud-Native Development
A beginner-friendly guide to .NET Aspire, the cloud-native stack that orchestrates your services, databases, and dashboards with one simple command.
How .NET Aspire Simplifies Service Discovery for Your Apps
Learn how .NET Aspire service discovery lets your services find each other by name, with no hardcoded URLs, ports, or environment headaches.