Deploy .NET Web Api to Azure App Service via Terraform

In this post I will describe how to deploy a .NET Web API on Azure App Services using Docker, MSSQL server instance, and a KeyVault for storing secrets. I leverage Terraform for infrastructure as code, providing detailed, step-by-step instructions for how to do it.

Azure / Azure DevOps Access

To be able to manage resources in Azure, the azurerm provider has to be configured with the following values:

provider "azurerm" {
features {}

client_id = var.arm_client_id
client_secret = var.arm_client_secret
tenant_id = var.arm_tenant_id
subscription_id = var.arm_subscription_id
}

I assume you already have this setup, but if you don’t, you will need to create an Azure Entra ID App Registration along with a valid credentials.

To get the code from Azure DevOps, the azuredevops provider requires only two things–organization url and personal access token. It’s more tricky because it expects a very specific environment variables, namely:

export AZDO_PERSONAL_ACCESS_TOKEN="..."
export AZDO_ORG_SERVICE_URL="https://dev.azure.com/yourorganizationname"

Dockerfile

The Dockerfile below defines how the application is built and deployed within a containerized environment. It uses a multi-stage build process to ensure the final image is lean, containing only the necessary runtime components. The base stage is based on the .NET ASP.NET runtime image, while the build and publish stages compile and prepare the application for deployment. The EXPOSE instruction specifies port 8080, which is internally used by Azure App Service as the default port for routing traffic to the container. If another port is preferred, this can be adjusted later in the Azure Web App’s configuration. Finally, the CMD command sets the application entry point to PK.Azure6.API.dll.

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 443

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ENV DOTNET_EnableWriteXorExecute=0
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["NuGet.Config", "."]
COPY ["src/PK.Azure6.API/PK.Azure6.API.csproj", "src/PK.Azure6.API/"]

COPY . .
WORKDIR "/src/src/PK.Azure6.API"
RUN dotnet restore "PK.Azure6.API.csproj"
RUN dotnet build "PK.Azure6.API.csproj" -c $BUILD_CONFIGURATION -o /app/build

FROM build AS publish
RUN dotnet publish "PK.Azure6.API.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
CMD ["dotnet", "PK.Azure6.API.dll"]

Providers

For Terraform, we are going to use the azurerm and azuredevops providers. The latter will be used to save the database password.

terraform {

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.5.0"
    }
    azuredevops = {
      source  = "microsoft/azuredevops"
      version = ">= 1.3.0"
    }
  }

  required_version = ">= 1.9.0"
}

Prerequisites

The Terraform snippet below sets up the resource group, storage account, storage container and container registry. The first tree are used for TerraForm backend, the last is for our docker images. You can put it in a environment.tf file and run manually.

resource "azurerm_resource_group" "resource-group" {
  name     = "${var.workload_lower}-${var.environment_lower}-rg"
  location = var.location
}

resource "azurerm_container_registry" "container-registry" {
  name                = "${var.workload_lower}${var.environment_lower}cr"
  resource_group_name = azurerm_resource_group.resource-group.name
  location            = azurerm_resource_group.resource-group.location
  sku                 = "Standard"
}

resource "azurerm_storage_account" "storage-account" {
  name                     = "${var.workload_lower}${var.environment_lower}sa"
  resource_group_name      = azurerm_resource_group.resource-group.name
  location                 = azurerm_resource_group.resource-group.location
  account_tier             = "Standard"
  account_replication_type = "GRS"
}

resource "azurerm_storage_container" "storage-container" {
  name                  = "${var.workload_lower}-${var.environment_lower}-sc"
  storage_account_name  = azurerm_storage_account.storage-account.name
  container_access_type = "private"
}

Obviously we’re not able to pass the above values into our main.tf backend configuration, so we will use the convention.

backend "azurerm" {
  resource_group_name  = "pkazure6-dev-rg"
  storage_account_name = "pkazure6devsa"
  container_name       = "pkazure6-dev-sc"
  key                  = "pkazure6-dev.tfstate"
}

Push Docker Image to ACR

When using Docker in your infrastructure, the CI/CD process changes slightly compared to traditional deployments. Typically, you would first provision your infrastructure (e.g., servers, compute resources) and then deploy your application code to it. However, with Docker, the process is reversed.

In Docker-based workflows, the compute resources (like virtual machines or container orchestration platforms such as Kubernetes) are configured to reference a specific Docker image tag. Instead of uploading your code directly to the compute resource, you build your application into a Docker image, push it to an Azure Container Registry (ACR), and then update the compute resource to pull and run the specific image version identified by its tag. This effectively shifts the focus from uploading code to managing and deploying containerized images.

In the following script snippet I am using docker commands and use 1.0.0 as a docker tag. In reality you will probably want to use the CI build identifier and a specialized pipeline task.

docker build --platform linux/amd64 -t pkazure8-dev:1.0.0 -f src/PK.Azure8.API/Dockerfile .
docker tag pkazure8-dev:1.0.0 pkazure8devcr.azurecr.io/pkazure8-dev:1.0.0
az acr login --name pkazure8devcr
docker push pkazure8devcr.azurecr.io/pkazure8-dev:1.0.0

Create the Infrastructure for Your Code

Now that the resources for TerraForm backend and an ACR exists (along with a docker image we pushed), it’s time to create the resources needed by the application.

terraform {
  backend "azurerm" {
    resource_group_name  = "pkazure8-dev-rg"
    storage_account_name = "pkazure8devsa"
    container_name       = "pkazure8-dev-sc"
    key                  = "pkazure8-dev.tfstate"
  }
  
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.5.0"
    }
    azuredevops = {
      source  = "microsoft/azuredevops"
      version = ">= 1.4.0"
    }
  }

  required_version = ">= 1.9.0"
}

locals {
  workload_formatted = "PK Azure8"
  workload_lower     = "pkazure8"
  environment_lower  = "dev"
  location           = "eastus2"
}

provider "azurerm" {
  features { }

  client_id       = var.arm_client_id
  client_secret   = var.arm_client_secret
  tenant_id       = var.arm_tenant_id
  subscription_id = var.arm_subscription_id
}  
  
module "core" {
  source = "../../modules/azure/core"

  workload_lower    = local.workload_lower
  environment_lower = local.environment_lower
}

module "data" {
  source = "../../modules/azure/data"

  workload_lower    = local.workload_lower
  environment_lower = local.environment_lower
  location          = local.location

  sql_administrator_login    = var.database_username
  sql_administrator_password = var.database_password

  # dependencies
  resource_group_name = module.core.resource_group_name
  key_vault_id        = module.core.key_vault_id
  
  depends_on = [module.core]
}

module "compute" {
  source = "../../modules/azure/compute"

  workload_lower    = local.workload_lower
  environment_lower = local.environment_lower
  location          = local.location

  # dependencies
  resource_group_name = module.core.resource_group_name
  key_vault_id        = module.core.key_vault_id
  key_vault_name      = module.core.key_vault_name
  docker_image        = var.docker_image
  # TODO: move into the module (by convention?)
  docker_registry     = "${local.workload_lower}${local.environment_lower}cr"
}

The above TerraForm code makes use of three modules: core, compute and the data. Let’s take a look at them one by one (I will skip the terraform block this time).

data "azurerm_client_config" "current" {}

data "azurerm_resource_group" "resource-group" {
  name = "${var.workload_lower}-${var.environment_lower}-rg"
}

resource "azurerm_key_vault" "key-vault" {
  name                        = "${var.workload_lower}-${var.environment_lower}-kv"
  location                    = data.azurerm_resource_group.resource-group.location
  resource_group_name         = data.azurerm_resource_group.resource-group.name
  enabled_for_disk_encryption = true
  tenant_id                   = data.azurerm_client_config.current.tenant_id
  soft_delete_retention_days  = 7
  purge_protection_enabled    = false

  sku_name = "standard"
}

resource "azurerm_key_vault_access_policy" "tf_user_access_policy" {
  key_vault_id = azurerm_key_vault.key-vault.id
  tenant_id    = data.azurerm_client_config.current.tenant_id
  object_id    = data.azurerm_client_config.current.object_id

  secret_permissions = ["Get","List","Delete","Recover","Backup","Restore","Set","Purge"]
}

The core module is pretty simple. It sets up the resource group, a key vault and the key vault access policy for the azure app registration identity that is provisioning the resources.

data "azurerm_client_config" "current" {}

data "azurerm_container_registry" "docker_registry" {
  name                = var.docker_registry
  resource_group_name = var.resource_group_name
}

resource "azurerm_service_plan" "app_service_plan" {
  
  name                = "${var.workload_lower}-${var.environment_lower}-asp"
  location            = var.location
  os_type             = var.os_type
  sku_name            = var.app_service_plan_sku
  resource_group_name = var.resource_group_name
}
 
resource "azurerm_linux_web_app" "app_service" {
  
  name                  = "${var.workload_lower}-${var.environment_lower}-as"
  location              = var.location
  service_plan_id       = azurerm_service_plan.app_service_plan.id
  https_only            = true
  resource_group_name   = var.resource_group_name

  app_settings = {
    ApplicationName = var.workload_lower
    EnvironmentName = var.environment_lower
  }
  
  site_config {
    minimum_tls_version = "1.2"
    container_registry_use_managed_identity = "true"
    application_stack {
      docker_image_name   = "${var.workload_lower}-${var.environment_lower}:${var.docker_image}"
      docker_registry_url = "https://${data.azurerm_container_registry.docker_registry.login_server}"
    }
  }
  
  identity {
    type = "SystemAssigned"
  }
}

resource "azurerm_role_assignment" "acr_access" {
  scope = data.azurerm_container_registry.docker_registry.id
  role_definition_name = "AcrPull"
  principal_id         = azurerm_linux_web_app.app_service.identity.0.principal_id
}

resource "azurerm_key_vault_access_policy" "app_service_access_policy" {
  key_vault_id = var.key_vault_id
  tenant_id = data.azurerm_client_config.current.tenant_id
  object_id = azurerm_linux_web_app.app_service.identity.0.principal_id

  secret_permissions = ["Get", "List"]
}

The compute module sets up the App Service and Linux Web App. Site section config of a web app sets up the details needed for the docker support. If you’ve selected port other than 8080 in your dockerfile you will also need to add the WEBSITES_PORT in the app_settings section.

We will also need to allow the web app to pull images from the ACR and access Key Vault: that is what azurerm_role_assignment and azurerm_key_vault_access_policy do correspondingly.

resource "azurerm_mssql_server" "sql-server" {
name = "${var.workload_lower}-${var.environment_lower}-sql"
location = var.location
resource_group_name = var.resource_group_name
version = "12.0"
administrator_login = var.sql_administrator_login
administrator_login_password = var.sql_administrator_password
public_network_access_enabled = true
}

resource "azurerm_mssql_database" "database" {
name = "${var.workload_lower}-${var.environment_lower}-db"
server_id = azurerm_mssql_server.sql-server.id
collation = "SQL_Latin1_General_CP1_CI_AS"
license_type = "LicenseIncluded"
max_size_gb = var.max_gb
sku_name = var.sql_sku
}

resource "azurerm_mssql_firewall_rule" "sql-server-firewall-rule" {
name = "${var.workload_lower}-${var.environment_lower}-fr"
server_id = azurerm_mssql_server.sql-server.id
start_ip_address = "0.0.0.0"
end_ip_address = "0.0.0.0"
}

resource "azurerm_key_vault_secret" "db-connection-string" {
name = "ConnectionStrings--Db"
value = "Server=tcp:${azurerm_mssql_server.sql-server.fully_qualified_domain_name},1433;User Id=${var.sql_administrator_login};Password=${var.sql_administrator_password};Initial Catalog=${azurerm_mssql_database.database.name};Encrypt=True;TrustServerCertificate=True;Connection Timeout=30;"
key_vault_id = var.key_vault_id
}

Last but not least, the data module sets up the mssql database resources along with connection string secret that is added to the Key Vault.

Summary

To be honest, I have a slight preference for AWS over Azure, but I do have to admit that TerraForm code for the Azure cloud is far shorter and more intuitive. Azure’s resources and modules feel less verbose and easier to navigate, making the overall experience more developer-friendly. Azure’s streamlined approach makes infrastructure-as-code not just a tool but a pleasure to work with, saving time and reducing complexity in deployment workflows.

If you’d like help deploying your .NET applications to Azure with Terraform, contact us at Trailhead for help.

Picture of Piotr Kolodziej

Piotr Kolodziej

Born and raised in Poland, Piotr did his first programming on Atari 800XL. He covered his first dial-up modem with a duve, so his mom wouldn’t hear it and freak over the internet bills. He graduated with a degree in Telecommunication and Business Application Programming. Piotr is a certified .NET and Azure Developer, and is passionate about excellent software architecture.

Free Consultation

Sign up for a FREE consultation with one of Trailhead's experts.

"*" indicates required fields

This field is for validation purposes and should be left unchanged.

Related Blog Posts

We hope you’ve found this to be helpful and are walking away with some new, useful insights. If you want to learn more, here are a couple of related articles that others also usually find to be interesting:

Our Gear Is Packed and We're Excited to Explore With You

Ready to come with us? 

Together, we can map your company’s software journey and start down the right trails. If you’re set to take the first step, simply fill out our contact form. We’ll be in touch quickly – and you’ll have a partner who is ready to help your company take the next step on its software journey. 

We can’t wait to hear from you! 

Main Contact

This field is for validation purposes and should be left unchanged.

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the form below. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Montage Portal

Montage Furniture Services provides furniture protection plans and claims processing services to a wide selection of furniture retailers and consumers.

Project Background

Montage was looking to build a new web portal for both Retailers and Consumers, which would integrate with Dynamics CRM and other legacy systems. The portal needed to be multi tenant and support branding and configuration for different Retailers. Trailhead architected the new Montage Platform, including the Portal and all of it’s back end integrations, did the UI/UX and then delivered the new system, along with enhancements to DevOps and processes.

Logistics

We’ve logged countless miles exploring the tech world. In doing so, we gained the experience that enables us to deliver your unique software and systems architecture needs. Our team of seasoned tech vets can provide you with:

Custom App and Software Development

We collaborate with you throughout the entire process because your customized tech should fit your needs, not just those of other clients.

Cloud and Mobile Applications

The modern world demands versatile technology, and this is exactly what your mobile and cloud-based apps will give you.

User Experience and Interface (UX/UI) Design

We want your end users to have optimal experiences with tech that is highly intuitive and responsive.

DevOps

This combination of Agile software development and IT operations provides you with high-quality software at reduced cost, time, and risk.

Trailhead stepped into a challenging project – building our new web architecture and redeveloping our portals at the same time the business was migrating from a legacy system to our new CRM solution. They were able to not only significantly improve our web development architecture but our development and deployment processes as well as the functionality and performance of our portals. The feedback from customers has been overwhelmingly positive. Trailhead has proven themselves to be a valuable partner.

– BOB DOERKSEN, Vice President of Technology Services
at Montage Furniture Services

Technologies Used

When you hit the trails, it is essential to bring appropriate gear. The same holds true for your digital technology needs. That’s why Trailhead builds custom solutions on trusted platforms like .NET, Angular, React, and Xamarin.

Expertise

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

  • Project Management
  • Architecture
  • Web App Development
  • Cloud Development
  • DevOps
  • Process Improvements
  • Legacy System Integration
  • UI Design
  • Manual QA
  • Back end/API/Database development

We partner with businesses who need intuitive custom software, responsive mobile applications, and advanced cloud technologies. And our extensive experience in the tech field allows us to help you map out the right path for all your digital technology needs.

Our Gear Is Packed and We're Excited to Explore with You

Ready to come with us? 

Together, we can map your company’s tech journey and start down the trails. If you’re set to take the first step, simply fill out the contact form. We’ll be in touch – and you’ll have a partner who cares about you and your company. 

We can’t wait to hear from you! 

Thank you for reaching out.

You’ll be getting an email from our team shortly. If you need immediate assistance, please call (616) 371-1037.