Skip to content

05. Infrastructure as Code

Use Bicep to provision reproducible Windows App Service infrastructure for ASP.NET Core 8, including diagnostics and deployment-ready outputs.

Infrastructure Context

Service: App Service (Windows, Standard S1) | Network: VNet integrated | VNet: ✅

This tutorial assumes a production-ready App Service deployment with VNet integration, private endpoints for backend services, and managed identity for authentication.

flowchart TD
    INET[Internet] -->|HTTPS| WA["Web App\nApp Service S1\nWindows .NET 8"]

    subgraph VNET["VNet 10.0.0.0/16"]
        subgraph INT_SUB["Integration Subnet 10.0.1.0/24\nDelegation: Microsoft.Web/serverFarms"]
            WA
        end
        subgraph PE_SUB["Private Endpoint Subnet 10.0.2.0/24"]
            PE_KV[PE: Key Vault]
            PE_SQL[PE: Azure SQL]
            PE_ST[PE: Storage]
        end
    end

    PE_KV --> KV[Key Vault]
    PE_SQL --> SQL[Azure SQL]
    PE_ST --> ST[Storage Account]

    subgraph DNS[Private DNS Zones]
        DNS_KV[privatelink.vaultcore.azure.net]
        DNS_SQL[privatelink.database.windows.net]
        DNS_ST[privatelink.blob.core.windows.net]
    end

    PE_KV -.-> DNS_KV
    PE_SQL -.-> DNS_SQL
    PE_ST -.-> DNS_ST

    WA -.->|System-Assigned MI| ENTRA[Microsoft Entra ID]
    WA --> AI[Application Insights]

    style WA fill:#0078d4,color:#fff
    style VNET fill:#E8F5E9,stroke:#4CAF50
    style DNS fill:#E3F2FD
flowchart TD
    A[Define Bicep parameters] --> B[Create App Service Plan reserved:false]
    B --> C[Create Web App netFrameworkVersion v8.0]
    C --> D[Apply site settings and diagnostics]
    D --> E[Export outputs for deployment]

Prerequisites

What you'll learn

  • How the guide's Bicep template models Windows App Service
  • Why reserved: false is required for Windows plans
  • How runtime metadata (netFrameworkVersion, CURRENT_STACK) is set
  • How outputs support manual deploy and Azure DevOps stages

Main content

1) Core deployment command

az deployment group create \
  --resource-group "$RESOURCE_GROUP_NAME" \
  --template-file "infra/main.bicep" \
  --parameters baseName="$BASE_NAME" location="$LOCATION" appServicePlanSku="B1" \
  --output table
Command/Code Purpose
az deployment group create --resource-group "$RESOURCE_GROUP_NAME" --template-file "infra/main.bicep" --parameters baseName="$BASE_NAME" location="$LOCATION" appServicePlanSku="B1" --output table Deploys the Bicep template to the target resource group with the supplied parameters.

2) Windows App Service plan essentials

In Bicep, Windows App Service must not be marked as Linux reserved.

resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = {
  name: appServicePlanName
  location: location
  sku: {
    name: appServicePlanSku
    tier: 'Basic'
  }
  kind: 'app'
  properties: {
    reserved: false
  }
}
Command/Code Purpose
resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' Declares the App Service plan resource in Bicep.
sku: { name: appServicePlanSku tier: 'Basic' } Sets the pricing SKU and tier for the hosting plan.
kind: 'app' Marks the plan as an App Service plan for web apps.
reserved: false Ensures the plan is created as Windows, not Linux.

3) Web app runtime settings for .NET 8

resource webApp 'Microsoft.Web/sites@2023-01-01' = {
  name: webAppName
  location: location
  kind: 'app'
  properties: {
    serverFarmId: appServicePlan.id
    siteConfig: {
      netFrameworkVersion: 'v8.0'
      metadata: [
        {
          name: 'CURRENT_STACK'
          value: 'dotnet'
        }
      ]
    }
  }
}
Command/Code Purpose
resource webApp 'Microsoft.Web/sites@2023-01-01' Declares the App Service site resource in Bicep.
serverFarmId: appServicePlan.id Links the web app to the App Service plan created earlier.
netFrameworkVersion: 'v8.0' Configures the Windows App Service runtime for .NET 8.
metadata: [{ name: 'CURRENT_STACK' value: 'dotnet' }] Sets stack metadata so the portal reflects the intended runtime.

These fields keep the portal/runtime aligned with the intended stack for Windows-hosted .NET apps.

4) App settings defined as code

resource appSettings 'Microsoft.Web/sites/config@2023-01-01' = {
  name: '${webApp.name}/appsettings'
  properties: {
    ASPNETCORE_ENVIRONMENT: 'Production'
    WEBSITE_RUN_FROM_PACKAGE: '1'
    APPLICATIONINSIGHTS_CONNECTION_STRING: appInsights.properties.ConnectionString
  }
}
Command/Code Purpose
resource appSettings 'Microsoft.Web/sites/config@2023-01-01' Declares the App Settings configuration resource for the web app.
ASPNETCORE_ENVIRONMENT: 'Production' Sets the app to run with production configuration by default.
WEBSITE_RUN_FROM_PACKAGE: '1' Tells App Service to run the app from a deployed package.
APPLICATIONINSIGHTS_CONNECTION_STRING: appInsights.properties.ConnectionString Injects the Application Insights connection string into app settings.

5) Outputs for downstream automation

output webAppName string = webApp.name
output webAppUrl string = 'https://${webApp.properties.defaultHostName}'
output appInsightsName string = appInsights.name
Command/Code Purpose
output webAppName string = webApp.name Exposes the deployed web app name for downstream scripts or pipelines.
output webAppUrl string = 'https://${webApp.properties.defaultHostName}' Exposes the default HTTPS URL of the deployed web app.
output appInsightsName string = appInsights.name Exposes the Application Insights resource name for later steps.

Outputs are consumed by deployment scripts and pipeline variable mapping.

6) Tie IaC choices to application code

var port = Environment.GetEnvironmentVariable("HTTP_PLATFORM_PORT")
    ?? Environment.GetEnvironmentVariable("PORT")
    ?? "5000";

builder.WebHost.UseUrls($"http://+:{port}");
builder.Services.AddApplicationInsightsTelemetry();
Command/Code Purpose
Environment.GetEnvironmentVariable("HTTP_PLATFORM_PORT") Reads the platform-provided port used by Windows App Service.
Environment.GetEnvironmentVariable("PORT") Falls back to another common hosting port variable.
builder.WebHost.UseUrls($"http://+:{port}") Binds the app to the resolved port so infrastructure and app agree.
builder.Services.AddApplicationInsightsTelemetry(); Enables telemetry collection that matches the provisioned monitoring resources.

Infrastructure and code should agree on runtime assumptions: port injection, telemetry connection string, and production environment.

7) Azure DevOps IaC stage example

- stage: Infra
  displayName: Provision Infrastructure
  jobs:
    - job: DeployBicep
      steps:
        - task: AzureCLI@2
          inputs:
            azureSubscription: $(azureSubscription)
            scriptType: bash
            scriptLocation: inlineScript
            inlineScript: |
              az deployment group create \
                --resource-group $(resourceGroupName) \
                --template-file infra/main.bicep \
                --parameters baseName=$(baseName) location=$(location) \
                --output table

Separate infra and app deploy in production

Keep infrastructure deployment idempotent and infrequent. Deploy app code frequently against stable infrastructure.

Verification

az webapp show --resource-group "$RESOURCE_GROUP_NAME" --name "$WEB_APP_NAME" --output json
Command/Code Purpose
az webapp show --resource-group "$RESOURCE_GROUP_NAME" --name "$WEB_APP_NAME" --output json Retrieves the deployed web app resource definition for verification.

Check:

  • App Service Plan kind is app (Windows)
  • reserved is false
  • Site runtime metadata reflects .NET stack
  • App Insights connection string exists in App Settings

Troubleshooting

Runtime mismatch in portal

Confirm siteConfig.netFrameworkVersion and metadata were applied in the deployed template.

Bicep deployment drift

Run a what-if before applying changes:

az deployment group what-if \
  --resource-group "$RESOURCE_GROUP_NAME" \
  --template-file "infra/main.bicep" \
  --parameters baseName="$BASE_NAME" location="$LOCATION"
Command/Code Purpose
az deployment group what-if --resource-group "$RESOURCE_GROUP_NAME" --template-file "infra/main.bicep" --parameters baseName="$BASE_NAME" location="$LOCATION" Previews infrastructure changes before applying the Bicep deployment.

Hidden dependency ordering issue

Split resources into modules and expose explicit outputs/inputs to avoid implicit timing assumptions.

CLI Alternative (No Bicep)

Use this imperative path when you need quick provisioning without changing your Bicep workflow.

Step 1: Set variables

SUBSCRIPTION_ID="<subscription-id>"
RG="rg-dotnet-tutorial"
LOCATION="koreacentral"
PLAN_NAME="plan-dotnet-tutorial-s1"
APP_NAME="app-dotnet-tutorial-abc123"
VNET_NAME="vnet-dotnet-tutorial"
INTEGRATION_SUBNET_NAME="snet-appsvc-integration"
Command/Code Purpose
SUBSCRIPTION_ID="<subscription-id>" Stores the Azure subscription used for imperative provisioning.
RG, LOCATION, PLAN_NAME, APP_NAME Define the core resource group, region, plan, and web app names.
VNET_NAME, INTEGRATION_SUBNET_NAME Define the networking resources used for VNet integration.
Expected output
Variables are set for deployment:
RG=rg-dotnet-tutorial
PLAN_NAME=plan-dotnet-tutorial-s1
APP_NAME=app-dotnet-tutorial-abc123

Step 2: Create resource group, plan, and app

az account set --subscription $SUBSCRIPTION_ID
az group create --name $RG --location $LOCATION
az appservice plan create --resource-group $RG --name $PLAN_NAME --sku S1
az webapp create --resource-group $RG --plan $PLAN_NAME --name $APP_NAME --runtime "DOTNETCORE|8.0"
Command/Code Purpose
az account set --subscription $SUBSCRIPTION_ID Sets the active subscription for the CLI session.
az group create --name $RG --location $LOCATION Creates the resource group for the manual deployment path.
az appservice plan create --resource-group $RG --name $PLAN_NAME --sku S1 Creates the App Service plan that will host the app.
az webapp create --resource-group $RG --plan $PLAN_NAME --name $APP_NAME --runtime "DOTNETCORE\|8.0" Creates the .NET 8 App Service instance.
Expected output
{
  "defaultHostName": "app-dotnet-tutorial-abc123.azurewebsites.net",
  "state": "Running"
}

Step 3: Configure app settings

az webapp config appsettings set --resource-group $RG --name $APP_NAME --settings ASPNETCORE_ENVIRONMENT=Production WEBSITE_RUN_FROM_PACKAGE=1
Command/Code Purpose
az webapp config appsettings set --resource-group $RG --name $APP_NAME --settings ASPNETCORE_ENVIRONMENT=Production WEBSITE_RUN_FROM_PACKAGE=1 Applies production environment and package-based deployment settings to the app.
Expected output
[
  {
    "name": "ASPNETCORE_ENVIRONMENT",
    "value": "Production"
  },
  {
    "name": "WEBSITE_RUN_FROM_PACKAGE",
    "value": "1"
  }
]

Step 4 (Optional): Add VNet integration

az network vnet create --resource-group $RG --name $VNET_NAME --location $LOCATION --address-prefixes 10.0.0.0/16
az network vnet subnet create --resource-group $RG --vnet-name $VNET_NAME --name $INTEGRATION_SUBNET_NAME --address-prefixes 10.0.1.0/24 --delegations Microsoft.Web/serverFarms
az webapp vnet-integration add --resource-group $RG --name $APP_NAME --vnet $VNET_NAME --subnet $INTEGRATION_SUBNET_NAME
Command/Code Purpose
az network vnet create --resource-group $RG --name $VNET_NAME --location $LOCATION --address-prefixes 10.0.0.0/16 Creates the virtual network for the manual provisioning path.
az network vnet subnet create --resource-group $RG --vnet-name $VNET_NAME --name $INTEGRATION_SUBNET_NAME --address-prefixes 10.0.1.0/24 --delegations Microsoft.Web/serverFarms Creates the delegated subnet required by App Service VNet integration.
az webapp vnet-integration add --resource-group $RG --name $APP_NAME --vnet $VNET_NAME --subnet $INTEGRATION_SUBNET_NAME Attaches the web app to the integration subnet.
Expected output
{
  "isSwift": true,
  "subnetResourceId": "/subscriptions/<subscription-id>/resourceGroups/rg-dotnet-tutorial/providers/Microsoft.Network/virtualNetworks/vnet-dotnet-tutorial/subnets/snet-appsvc-integration"
}

Step 5: Validate effective configuration

az webapp config show --resource-group $RG --name $APP_NAME --query "{netFrameworkVersion:netFrameworkVersion, windowsFxVersion:windowsFxVersion}" --output json
az webapp config appsettings list --resource-group $RG --name $APP_NAME --query "[?name=='ASPNETCORE_ENVIRONMENT' || name=='WEBSITE_RUN_FROM_PACKAGE']" --output json
Command/Code Purpose
az webapp config show --resource-group $RG --name $APP_NAME --query "{netFrameworkVersion:netFrameworkVersion, windowsFxVersion:windowsFxVersion}" --output json Checks the effective runtime stack configuration on the deployed app.
az webapp config appsettings list --resource-group $RG --name $APP_NAME --query "[?name=='ASPNETCORE_ENVIRONMENT' || name=='WEBSITE_RUN_FROM_PACKAGE']" --output json Verifies the key App Settings applied to the app.
Expected output
{
  "netFrameworkVersion": "v8.0",
  "windowsFxVersion": null
}

Run It in the Portal

Portal view: Resource group overview (verifying Bicep deployment outputs)

Resource group overview blade for rg-test-20251107 in Korea Central showing an Essentials panel with Subscription "Visual Studio Enterprise Subscription", Subscription ID 00000000-0000-0000-0000-000000000000, Deployments "2 Failed, 4 Succeeded", and Location "Korea Central". The Resources tab lists 11 related resources grouped by type, including Application Insights (ai-test-20251107), App Service (app-test-20251107), App Service Domain (app-test-20251107.net and domain-test-20251107.com), DNS Zone (app-test-20251107.net and domain-test-20251107.com), Microsoft.Web certificate (app-test-20251107.net-app-test-20251107), App Service (Slot) (staging (app-test-20251107/staging)), Application Insights Smart Detection, Action group, App Service Plan (asp-test-20251107), and Failure Anomalies smart detector alert rule. The command bar includes Create, Manage view, Delete resource group, Refresh, Export to CSV, Open query, Assign tags, Move, Delete, and Export template buttons.

After running az deployment group create --template-file ./infra/main.bicep, the resource group overview is the Portal surface for checking what the deployment produced. The visible Resources tab shows App Service, App Service Plan, Application Insights, and other related rows grouped under one resource group, while the Deployments entry in Essentials is the link to follow when you need deployment history after a partial failure. The Export template button is also useful when you want to compare the deployed ARM state with the Bicep source in your repository.

See Also

Sources