Deploy a .NET & Angular application to an Azure App Service with multiple environments using an Azure Pipeline and Bicep

In this article, I will show you a full tutorial on how to create a .NET application using Angular for the frontend and deploy this application with Azure DevOps pipelines on multiple App Service environments.

You are going to learn:

You can find the code for this tutorial on my GitHub account.

1. Initialize a new Git repository

First, navigate to a local folder that will host the application and initialize a new git repository with the git init command.

Next, start a new Visual Studio Code window from the current repository. For that, type code . in the path input-field in the file explorer.

2. Create the .NET application

Inside Visual Studio Code, open a new terminal and type the following command:

dotnet new angular -o dotnet-angular-application

This will bootstrap a new C# project that uses Angular for the frontend. In this tutorial, we do not focus on the content of the application but on the DevOps part of it, so we leave the application code as is for the moment.

The files created contain a .gitignore file. Open it and on a new line add .vscode. This way, we avoid checking in this folder, which is only needed locally.

Stage and commit all changes with the git add --all and then the git commit -m "init commit" commands.

3. Create a new Azure DevOps project

Navigate to the Azure DevOps website of your organization and create a new project for hosting the application. I named the project dotnet-angular-application.

Copy the following commands and adapt the XXXs based on the URL of your organization to wire your local repository to the new Azure DevOps online repository.

git remote add origin https://[email protected]/XXX/dotnet-angular-application/_git/dotnet-angular-application
git push -u origin --all

4. Create a new pipeline

In the root folder of your application, create a new azure-pipeline.yml file. Since in this tutorial I want to give you a real-life example of a build and release pipeline structure, I am going to use multiple stages in our pipeline and split them into separate files by using the template keyword.

Copy the following content into the azure-pipeline.yml file.

trigger:
- main

pool:
  vmImage: 'ubuntu-latest'

# Define the available environments as a list of strings and use them in the following for-loop for deploying to multiple environments
parameters:
  - name: environments
    type: object
    default:
      - dev
      - prod

stages:
- stage: AngularApplication
  jobs:
  - template: ./pipelines/templates/angular-application.yml
- stage: DotnetApplication
  jobs:
  - template: ./pipelines/templates/dotnet-application.yml
- ${{ each env in parameters.environments }}: # Create a new stage for each environment
  - stage: DeployTo${{ env }}
    dependsOn: DotnetApplication
    condition: succeeded()
    jobs:
        - template: ./pipelines/templates/deploy-to-environment.yml
          parameters:
            environment: ${{ env }}

As you can see, the azure-pipeline.yml file works as the orchestrator for the included job templates. You can find more information about the different template types on this Microsoft Learn article.

We are using in total three templates:

Before using these environments, you will have to first create them in Azure DevOps. Navigate to Pipelines -> Environments and click on New environment:

Create a new environment in Azure DevOps

Repeat the same step for the prod environment. However, this time you will add a manual approval step. Click on the Approvals and checks tab, then on View all checks and then click on the Approvals option and click on Next:

Create a new Approval option for Environment

Define the users who can approve the deployment on the prod environment and click on Create.

5. Create the template for building the Angular application

If you created the application by using the dotnet command provided at the beginning of the tutorial, then inside the .csproj file of your project you can see that both Angular and .NET applications are built when we start the application locally with F5 or when you run the dotnet build command.

This means that we could simply use the dotnet build command in our pipeline and build both applications. However, since we want some separation of concerns, we are going to split the build into two.

Create a new pipelines folder on the root of your application and inside it a new templates folder with a new angular-application.yml file. Add the following content in it and pay extra attention to the displayName of the tasks and the comments in order to understand what is happening and why.

jobs:
  - job: BuildAndPublish
    displayName: 'Build and publish'
    steps:
    # Since the Angular application is inside the ClientApp folder, we first have to navigate there
    - script: |
        cd ./ClientApp
        npm install -g @angular/cli
        npm install
      displayName: 'Install Angular CLI and npm packages'

    - task: NodeTool@0
      displayName: 'Install Node.js'
      inputs:
        versionSpec: '18.x'
      
    # The ng build will create a dist folder with the build content
    - script: |
        cd ./ClientApp
        ng build --configuration production
      displayName: 'Build Angular Project'

    - task: CopyFiles@2
      displayName: 'Copy files from dist to artifactstagingdirectory'
      inputs:
        SourceFolder: './ClientApp/dist'
        Contents: '**'
        TargetFolder: '$(build.artifactstagingdirectory)' 

    - task: PublishBuildArtifacts@1
      displayName: 'Publish Build Artifacts inside angularApp folder'
      inputs:
        PathtoPublish: '$(Build.ArtifactStagingDirectory)'
        ArtifactName: 'angularApp'

The next step is to update the .csproj file and allow it to build the Angular application only on Debug mode. To achieve that, we add the Condition=" '$(Configuration)' == 'Debug'" condition.

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <SpaRoot>ClientApp\</SpaRoot>
    <SpaProxyServerUrl>https://localhost:44454</SpaProxyServerUrl>
    <SpaProxyLaunchCommand>npm start</SpaProxyLaunchCommand>
    <RootNamespace>AngularDotNetApplication</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="7.0.18" />
  </ItemGroup>

  <ItemGroup>
    <!-- Don't publish the SPA source files, but do show them in the project files list -->
    <Content Remove="$(SpaRoot)**" />
    <None Remove="$(SpaRoot)**" />
    <None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
  </ItemGroup>

    <Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition="'$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules')">
    <!-- Ensure Node.js is installed -->
    <Exec Command="node --version" ContinueOnError="true">
      <Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
    </Exec>
    <Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
    <Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
  </Target>
  
  <Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish" Condition="'$(Configuration)' == 'Debug'">
    <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
    <Exec WorkingDirectory="$(SpaRoot)" Command="npm run build -- --configuration production" />

    <!-- Include the newly-built files in the publish output -->
    <ItemGroup Condition="'$(Configuration)' == 'Debug'">
      <DistFiles Include="$(SpaRoot)dist\**; $(SpaRoot)dist-server\**" />
      <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
        <RelativePath>wwwroot\%(RecursiveDir)%(FileName)%(Extension)</RelativePath>
        <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
      </ResolvedFileToPublish>
    </ItemGroup>
  </Target>
</Project>

6. Create the template for building the .NET application

Inside the pipelines/templates folder add a new dotnet-application.yml file. Add the following content in it and pay extra attention to the displayName of the tasks in order to understand what is happening and why.

jobs:
  - job: BuildAndPublish
    displayName: 'Build and publish'
    variables:
      buildConfiguration: 'Release'
    steps:
    - task: UseDotNet@2
      inputs:
        packageType: 'sdk'
        version: '8.x'
        installationPath: $(Agent.ToolsDirectory)/dotnet

    - task: DotNetCoreCLI@2
      displayName: Restore
      inputs:
        command: restore
        projects: '**/*.csproj'

    - task: DotNetCoreCLI@2
      displayName: Build
      inputs:
        command: build
        projects: '**/*.csproj'
        configuration: $(buildConfiguration)

    - task: DotNetCoreCLI@2
      displayName: Publish
      inputs:
        command: publish
        publishWebProjects: true
        arguments: '--configuration $(BuildConfiguration) --output $(Build.ArtifactStagingDirectory)'
        zipAfterPublish: false

    - task: PublishBuildArtifacts@1
      displayName: 'Publish Build Artifacts inside dotnetApp folder'
      inputs:
        PathtoPublish: '$(Build.ArtifactStagingDirectory)'
        ArtifactName: 'dotnetApp'   

7. Create a template for deploying the application

Inside the pipelines/templates folder add a new deploy-to-environment.yml file. Add the following content in it and pay extra attention to the displayName of the tasks in order to understand what is happening and why.

# Gets the environment name from the caller and use it in tasks 
parameters:
  - name: environment
    type: string

jobs:
  - deployment: Deploy${{ parameters.environment }} # Each job has to have a unique name, so we use the environment name
    environment: ${{ parameters.environment }}
    variables:
      location: 'XXX' # Define the value you want for location
      webAppName: 'XXX' # Define the value you want for the name of the application
      slotName: '${{ parameters.environment }}-slot'
      resourceGroupName: 'XXX' # Define the value you want for the Azure Resource Group name
      templateFile: './pipelines/bicep/main.bicep'
      parametersFile: './pipelines/bicep/main.bicepparam'
    strategy:
      runOnce:
        deploy:
          steps:
          - checkout: self # avoids issue with bicep files not being found.
          - task: DownloadPipelineArtifact@2
            inputs:
              artifact: 'angularApp'
              path: '$(Build.ArtifactStagingDirectory)/angularApp/'
          - task: DownloadPipelineArtifact@2
            inputs:
              artifact: 'dotnetApp'
              path: '$(Build.ArtifactStagingDirectory)/dotnetApp/'

            # We combine the two applications in one new folder. The Angular app is stored inside a new wwwroot folder. This folder is then going to be served from the WebApp
          - script: |
              mkdir -p $(Build.ArtifactStagingDirectory)/application
              cp -r $(Build.ArtifactStagingDirectory)/dotnetApp/s/* $(Build.ArtifactStagingDirectory)/application/
              cp -r $(Build.ArtifactStagingDirectory)/angularApp/* $(Build.ArtifactStagingDirectory)/application/wwwroot
            displayName: 'Combine Artifacts'

            # Zip the whole content of the application folder together
          - task: ArchiveFiles@2
            displayName: 'Zip all files under /application/ together'
            inputs:
              rootFolderOrFile: '$(Build.ArtifactStagingDirectory)/application'  # The folder or file to archive
              includeRootFolder: false  # Whether to include the root folder in the archive
              archiveType: 'zip'  # The type of archive to create
              archiveFile: '$(Build.ArtifactStagingDirectory)/application.zip'  # The path to the output archive file
              verbose: true

          - task: AzureCLI@2
            displayName: 'Deploy Bicep to ${{ parameters.environment }} Environment'
            inputs:
              azureSubscription: 'XXX' # Define a new Azure DevOps Service Connection and use its name here
              scriptType: bash
              scriptLocation: inlineScript
              useGlobalConfig: false
              inlineScript: |
                az --version
                az group create --name $(resourceGroupName) --location $(location)
                az deployment group create --resource-group $(resourceGroupName) --template-file $(templateFile) --parameters $(parametersFile)

          - task: AzureCLI@2
            displayName: 'Create ${{ parameters.environment }} Deployment Slot'
            inputs:
              azureSubscription: 'XXX' # Use the same name of the Service Connection as before
              scriptType: bash
              scriptLocation: inlineScript
              useGlobalConfig: false
              inlineScript: |
                az webapp deployment slot create --name $(webAppName) --resource-group $(resourceGroupName) --slot $(slotName)

          - task: AzureWebApp@1
            inputs:
              azureSubscription: 'XXX' # Use the same name of the Service Connection as before
              appType: 'webApp'
              appName: $(webAppName)
              package: '$(Build.ArtifactStagingDirectory)/application.zip'
              slotName: $(slotName)
            displayName: 'Deploy to ${{ parameters.environment }} Slot'

In order to create a new Service Connection in Azure DevOps follow the steps described in this Microsoft article.

8. Create a new Bicep file for the needed Azure infrastructure

Bicep is a great way for implement Infrastructure as Code (IaC) for creating new Azure Resources. Our application is hosted on a Linux WebApp and we also want to create an MSSQL database for feature use.

Inside the pipelines folder add a new bicep folder and inside it a main.bicep file. Copy the following content into it:

@description('The name of the SQL logical server.')
param serverName string

@description('The name of the SQL Database.')
param sqlDBName string

@description('Location for all resources.')
param location string

@description('The administrator username of the SQL logical server.')
param administratorLogin string

@description('The administrator password of the SQL logical server.')
@secure()
param administratorLoginPassword string

@description('The name of the App Service plan.')
param appServicePlanName string

@description('The name of the App Service.')
param webAppName string

resource sqlServer 'Microsoft.Sql/servers@2023-08-01-preview' = {
  name: serverName
  location: location
  properties: {
    administratorLogin: administratorLogin
    administratorLoginPassword: administratorLoginPassword
  }
}

resource sqlDB 'Microsoft.Sql/servers/databases@2023-08-01-preview' = {
  parent: sqlServer
  name: sqlDBName
  location: location
  sku: {
    name: 'Basic'
    tier: 'Basic'
  }
}

resource appServicePlan 'Microsoft.Web/serverfarms@2023-12-01' = {
  name: appServicePlanName
  location: location
  properties: {
    reserved: true
  }
  sku: {
    name: 'S1' // This is the minimum pricing plan which supports deployment slots. If you use a Free plan, then you will have to deactivate the step which creates the deployment slots
    tier: 'Standard'
  }
  kind: 'linux' // Do not forget to add this, otherwise you get the error that linuxFxVersion: 'DOTNETCORE|8.0' is not recognized
}

resource webApp 'Microsoft.Web/sites@2023-12-01' = {
  name: webAppName
  location: location
  kind: 'app,linux'
  properties: {
    serverFarmId: appServicePlan.id
    httpsOnly: true
    siteConfig: {
      linuxFxVersion: 'DOTNETCORE|8.0'
      appSettings: [
        {
          name: 'WEBSITE_RUN_FROM_PACKAGE' // Do not forget to add this, since we run the application from a .zip file
          value: '1'
        }
      ]
    }
  }
}

Next, create a new main.bicepparam file inside the pipelines/bicep folder, add the following content, and update the values of the parameters:

using 'main.bicep'

// Define the values for each one of this parameters based on your needs.
param serverName = 'XXX'
param sqlDBName = 'XXX'
param location = 'XXX'
param administratorLogin = 'XXX'
param administratorLoginPassword = 'XXX'
param appServicePlanName = 'XXX'
param webAppName = 'XXX'

9. Test the pipeline on Azure DevOps

Navigate to your Azure DevOps instance and then click on Pipelines and then on New pipeline. Select Azure Repos Git, select the repository we created for this tutorial and then select the following option:

Add an existing Azure pipeline yaml file when creating a pipeline

With your pipeline created, click on Run to run it for the first time. If you did every step described in this tutorial correctly, a new pipeline instance with four stages is created and after some minutes finishes by creating all the new Azure Resources and deploying the app on a new Azure WebApp.

If you face any kinds of errors, feel free to contact me with a comment and I will get back to you.

Conclusion

I believe this tutorial was more than a basic example on the question “How to build and deploy a .NET application?”. I tried to give you the way of reusing .yml files, combining the deployment with Infrastructure as Code and deploying on multiple environments. I hope you enjoy the tutorial as much as I did writing it :)

comments powered by Disqus