Deploying .NET Applications To Azure

Updated 14/07/2025

Overview of my notes for Deploying .NET Applications To Azure, the idea is to be able to follow them again later and apply to other deployments. Also see Errors When Deploying .NET Applications To Azure and Finding Container Apps Logs In Azure

For a guided tutorial I highly recommend Dometrain - From Zero to Hero: Deploying .NET Applications to Azure by Mohamad Lawand

Example container app ingress is https://demo-aca-bb8c7a39-dev.bluefield-f45026b3.australiaeast.azurecontainerapps.io/swagger/index.html, so demo-aca-bb8c7a39-dev comes from container_app.tf

Steps

Each heading shows the steps I followed:

Local Setup: CLI
  1. Install Terraform CLI and Azure CLI
  2. Check they available after installing
1
2
terraform version                ~ example v1.9.8
az version ~ example 2.67.0

Local Setup: Sample code
  1. Clone DemoApi.

I built this simple CRUD(ish) app based on the Microsoft templates and adapted it to use EF Core and Postgres

  1. Run the database locally using docker compose, there are 2 dbs because the tutorial used MS SQL but PGSQL is cheaper and the one that I would use for actual deployments.
1
docker compose up

The default passwords are

1
2
3
4
Type        | User        | Password                             | Port
------------------------------------------------------------------------
MS SQL | sa | 00000000-0000-0000-0000-919333ac7aaf | 1433
PostgreSQL | postgres | 00000000-0000-0000-0000-919333ac7aaf | 5432
  1. Run the API, its .NET 8.0 so still has Swagger. The DB migrations should run automagically when the app starts.
1
dotnet run

Swagger is enabled for all environments so I can use the Swagger UI when deployed to Azure, normally this is only for local development or demo purposes.

The manual steps to run the migrations when testing were as follows

1
2
3
4
5
dotnet tool install --global dotnet-ef                                                     ~ globally install the EF tooling
dotnet build ~ build the solution

dotnet ef migrations add Initial_Migration --project .\src\DemoApi\DemoApi.csproj ~ add a migration based on the application models
dotnet ef database update --project .\src\DemoApi\DemoApi.csproj ~ apply the migration
  1. Check the app can locally insert and read data from the database

Create Azure Infrastructure With Terraform

You will need to know your tenant id, its just a GUID like 00000000-0000-0000-0000-000000000001

  1. Login to Azure, select and note the subscription
1
2
az login --tenant 00000000-0000-0000-0000-000000000001       ~ logs you into the tenant, will open Azure portal login
az account show --query tenantId ~ shows which tenant you are logged in as
  1. Copy vars.tf from iac_example into iac and update some values
  • subscription_id which came from az login (this is just temp while creating the infra from local)
  • sql_pass, example dfb18358-5994-4470-b75a-109981a3fcf9, this is just a random GUID
  • sql_user, example sqldemo-admin
  • group_key, example bb8c7a39 (Some Azure resources need to be unique, while testing its helpful to use the start of a random GUID)

This CLI command was helpful when I was trying to understand the regions which I stored as location in vars.tf

1
az account list-locations -o table      ~ list of Azure regions
  1. Copy setup.tf and check the hashicorp/azurerm provider version is current, example 4.14.0
  • sets the cloud provider, API versions and account to connect to by subscription id
  • see azurerm
  1. Run terraform init
1
2
3
terraform init    ~ local initialization, and tracking with `.terraform.lock.hcl` to record the provider selections made
~ Per the docs: Include this file in your version control repository
~ so that Terraform can guarantee to make the same selections by default when you run "terraform init" in the future.
  1. Copy the files listed below, one at a time, in order of the list, each time run terraform plan and terraform apply, this will create the infrastructure needed to run the application in Azure
1
2
3
4
terraform plan    ~ test run, compare local tf with azure, show the difference

terraform apply ~ execute, should create the resources described in tf plan
~ this will then create `terraform.tfstate` which is used to compare local to Azure

Note: azurerm_postgresql_flexible_server replaces the single server used March 28, 2025

The end result seen from Azure showed the resources in my Resource Group:

Resources created in Azure

You can also view the groups from the CLI

1
az group list --output table            ~ list resource groups

You can also format the terraform files with terraform fmt myfile.tf


Test Connect To SQL

The username and DNS shown here was just for the demo, best practice is never to commit or share any secrets.

I then added my own IP address to the firewall by navigating to Resource Group (demo-rg-bb8c7a39-dev) -> selected the SQL server (demo-sql-bb8c7a39-dev) -> Security -> Networking -> Firewall rules -> Add your client IPv4 address (xxx.xxx.xxx.xxx) -> Azure automagically filled mine in -> Rule name Carl home IP -> Save. Your IP wont be static so will probs need an update later.

I then needed to get the server address for SQL in Azure by navigating to SQL server (demo-sql-bb8c7a39-dev), my server name was demo-sql-bb8c7a39-dev.database.windows.net, so the suffix comes from mssql.tf where we set the server name

Based on the sql_pass/user values in vars.tf I build then connected using DBeaver

Test sql connection


Create Github Secrets and Variables

You set these in the Github UI under Github Settings -> Secrets and variables -> actions

  • Secrets tab -> Repository secrets -> New repository secret
  • Variables tab -> Repository variables -> New repository variable

For Azure Container Registry

To get these values navigate to Container Registry demoacrbb8c7a39dev -> Settings -> Access keys

1
2
3
${{ vars.ACR_SERVER }}              ~ demoacrbb8c7a39dev.azurecr.io
${{ secrets.ACR_USER }} ~ demoacrbb8c7a39dev
${{ secrets.ACR_PASSWORD }} ~ 000000000000000000000/000000000000000000000000000000000000

For Logging into Azure

Create a Service Principle (SP) and configure its access to Azure resources, here we copy the entire JSON object and save as AZ_CREDENTIALS github secret. This SP is used when logging into Azure to deploy both the Application and IAC.

1
az ad sp create-for-rbac --name github-auth --role contributor --scopes /subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/demo-rg-bb8c7a39-dev --json-auth --output json
1
${{ secrets.AZ_CREDENTIALS }}

For IAC (RBAC)

Create a another Service Principle (SP), here we copy values out of the JSON and save as individual github secrets. This SP has no role or scopes and is used with the terraform deployment itself.

1
az ad sp create-for-rbac --name iac-terraform-auth
1
2
3
4
${{ secrets.ARM_CLIENT_ID }}         ~ appId
${{ secrets.ARM_CLIENT_SECRET }} ~ password
${{ secrets.ARM_SUBSCRIPTION_ID }} ~ you get this from `az login` or the Azure portal under and resource
${{ secrets.ARM_TENANT_ID }} ~ tenant

For IAC (SQL Secrets)

These are the values manually added to to vars.tf

1
2
${{ secrets.TF_VAR_DB_PASSWORD }}    ~ used with `Terraform plan` as var `sql_pass`, example dfb18358-5994-4470-b75a-109981a3fcf9
${{ secrets.TF_VAR_DB_USER }} ~ used with `Terraform plan` as var `sql_user`, example sqldemo-admin

This will look something like Server=demo-sql-bb8c7a39-dev.database.windows.net,1433; Initial Catalog=demo_db; User=sqldemo-admin; Password=dfb18358-5994-4470-b75a-109981a3fcf9; Encrypt=False;

ENSURE its added wrapped in quotes

1
${{ secrets.AZ_MSSQL_DB_CONN }}      ~ used when deploying, changes app settings ConnectionStrings__SqlServer

Github Actions - Deploy Application
  1. Copy \.github\examples\deploy-api.yaml to \.github\workflows\deploy-api.yaml
  2. Update the config values, potentially this could be from a config but this is more intentional and these values wont change for the applications life span.
1
2
3
4
demoacrdev (few instance values)     -> demoacrbb8c7a39dev
containerAppName -> demo-aca-bb8c7a39-dev
containerAppEnvironment -> demo-cae-bb8c7a39-dev
resourceGroup -> demo-rg-bb8c7a39-dev
  1. Commit it and check it deploys, example https://github.com/carlpaton/deploying-dotnet-azure/actions/runs/12670053312

Overview of deploy-api.yaml

  • JOB:
    • build-and-push-image
      • STEPS:
        • Checkout repository
        • Setup WebApi .NET
        • Configure Azure Container Registry (ACR)
        • Get commit SHA
        • Build and push image to Azure Container Registry (ACR)
    • deploy-image-to-container-service
      • STEPS
        • Login to Azure
        • Deploy to Azure Container Apps (ACA)

Github Actions - Deploy IAC

We need to migrate the TF state, terraform.tfstate and terraform.tfstate.backup from being stored locally to being stored in the cloud.

  1. Create a new resource group in Azure using the portal -> Resource groups -> Create
  • Name -> reference-rg
  • Region -> (Asia Pacific) Australia East
  • Tags -> environment=dev, source=azure-portal, group_key=bb8c7a39
  • Review and create -> Create
  1. Add storage in the portal -> reference-rg -> create -> search -> storage account -> select Storage account by Microsoft | Azure Service
  • Plan -> Storage account -> Create
  • Storage account name -> demoiacbb8c7a39
  • Region -> (Asia Pacific) Australia East
  • Primary service -> Azure Blob Storage or Azure Data Lake Storage Gen 2
  • Performance -> Standard
  • Redundancy -> Locally-redundant storage (LRS)
  • Review and create -> Createthis will take a minute or so to create and says Deployment is in progress
  • Go to resource -> Data storage -> Containers -> + Container (New Container)
  • Name -> terraform -> Create
  1. Update setup.tf to include the backend
1
2
3
4
5
6
7
8
9
10
11
terraform {
...

backend "azurerm" {
resource_group_name = "reference-rg"
storage_account_name = "demoiacbb8c7a39"
container_name = "terraform"
key = "terraform.tfstate"
}

}
  1. Migrate the terraform files from local to the new blob storage
  • run terraform plan, this should error with Error: Backend initialization required, please run "terraform init" because we added a new backend
  • run terraform init, this will ask Do you want to copy existing state to the new backend?, type yes

tfstate was copied to blob storage

Locally terraform.tfstate should now be blank.

  1. Copy \.github\examples\deploy-iac.yaml to \.github\workflows\deploy-iac.yaml, no changes needed, commit and push.

Overview of deploy-iac.yaml

  • JOB:
    • terraform-deploy
      • STEPS:
        • Checkout repository
        • Login to Azure
        • Terraform install
        • Terraform initialization
        • Terraform validate
        • Terraform plan
        • Terraform apply
        • Notify failure
  1. Add a tag change, commit & push
1
2
3
4
5
6
7
8
resource "azurerm_resource_group" "demo-rg" {
...

tags = {
...
testtag = "testtag1"
}
}

If there are state lock issues run like Error: Error acquiring the state lock -> Error message: state blob is already locked then this can be unlocked from the CLI where 092ff1c9-467a-39a4-d0cf-b72ae52e5805 is the lock ID

1
terraform force-unlock -force 092ff1c9-467a-39a4-d0cf-b72ae52e5805

This should now deploy IAC changes.


References

Technologies Used

Costings