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.


