Deploying docker image to Azure with yml and bicep through Github Actions

How to deploy a Blazor Server with PostgreSQL docker image and infrastructure to Azure using GitHub Actions

Deploying docker image to Azure with yml and bicep through Github Actions

Built on the sholders of giants

But before I continue I want to point out I used lot of information from Anto Subsh blog (at one point I was almost giving up and wanted to buy few hours of consulting from him) but I'm quite stubborn so I didn't give up even though this took me over two weeks to finish (nb. I'm on a holiday and not spending all day on this)!

But I also used ChatGPT, the bing chat and Code Pilot extensively and from that experience I can say that I don't feel like its likely that programmers will loose their jobs to AI anytime soon. These tools helped me allot to get going but often they took me on some wild tangent of "two steps ahead and seven back".

I'm getting much better at wielding these new technologies but there are two things you need to have to succeed in using it where the first it to create good context and then form the question in a good way and then its its being super critical of what you are offered and don't just accept it right away just because you are not the expert in it.

But this is the future and who doesn't want to have 1-2 extra hands helping you writing the boring code.

CI/CD steps from A-Z

Here I'm going to share with you the steps I had to take to make my CI/CD work so that when I check in code to a branch it will run tests and build the code. And when I merge to main it will deploy to my dev environment and when I tag the main branch it will deploy to production.

Create a Github Action Workflow yml file

Navigate to the folder where your .git folder is and create a .github folder and in that one create a workflows folder and create the file docker-build-and-deploy.yml and paste in the following code.

Note that you need to manually create the Azure Container Registry in your Azure portal for this to work.

name: Version, Build, Provision Infrastructure, and Deploy

on:
  push:
    branches:
      - main
    tags:
      - 'v*'  # This will match tags like v1.0, v2.0.1, etc. and is used to deploy to production when the main branch is tagged
  pull_request:
    branches:
      - main

env:
  BICEP_FILE_PATH: TodoApp/LF.TodoApp/aspnet-core/Main.bicep

  # Development Environment Configuration 
  DEV_APPSERVICE_PLAN: B1
  DEV_RESOURCE_GROUP: todo-acr-dev-rg 
  DEV_LOCATION: northeurope
  DEV_AZURE_WEBAPP_NAME: td-d-TodoApp 
  DEV_AZURE_CONTAINER_REGISTRY: todoacrdev.azurecr.io   
  DEV_POSTGRESQL_SERVER_NAME: todo-d-ser  # max 10 letters
  DEV_POSTGRESQL_DATABASE_NAME: TodoAppdb            
  DEV_POSTGRESQL_ADMIN_LOGIN: ${{ secrets.DEV_POSTGRESQL_ADMIN_LOGIN }}
  DEV_POSTGRESQL_ADMIN_PASSWORD: ${{ secrets.DEV_POSTGRESQL_ADMIN_PASSWORD }}
  DEV_POSTGRESQL_SKU_NAME: Standard_B1ms
  DEV_POSTGRESQL_SKU_TIER: 'burstable' # or 'generalpurpose' or 'memoryoptimized'
  DEV_POSTGRESQL_STORAGE: 32  # GB

  # Production Environment Configuration
  PROD_APPSERVICE_PLAN: B1
  PROD_RESOURCE_GROUP: your-prod-resource-group
  PROD_LOCATION: your-prod-location
  PROD_AZURE_WEBAPP_NAME: td-p-TodoApp #prod
  PROD_AZURE_CONTAINER_REGISTRY: your-prod-acr-name.azurecr.io
  PROD_POSTGRESQL_DATABASE_NAME: todo-db-prod
  PROD_POSTGRESQL_SERVER_NAME: todo-postgresql-prod
  PROD_POSTGRESQL_ADMIN_LOGIN: ${{ secrets.PROD_POSTGRESQL_ADMIN_LOGIN }}
  PROD_POSTGRESQL_ADMIN_PASSWORD: ${{ secrets.PROD_POSTGRESQL_ADMIN_PASSWORD }}
  PROD_POSTGRESQL_SKU_NAME: Standard_B1ms
  PROD_POSTGRESQL_SKU_TIER: 'generalpurpose' # or 'burstable' or 'memoryoptimized'
  PROD_POSTGRESQL_STORAGE: 32 # GB

jobs:
  test:
    name: Run Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

      - name: Run All Tests
        run: |
          dotnet test TodoApp/LF.TodoApp/aspnet-core/test/LF.TodoApp.Application.Tests/LF.TodoApp.Application.Tests.csproj --logger "trx;LogFileName=ApplicationTestResults.trx" --results-directory test/results
          dotnet test TodoApp/LF.TodoApp/aspnet-core/test/LF.TodoApp.Domain.Tests/LF.TodoApp.Domain.Tests.csproj --logger "trx;LogFileName=DomainTestResults.trx" --results-directory test/results
          dotnet test TodoApp/LF.TodoApp/aspnet-core/test/LF.TodoApp.EntityFrameworkCore.Tests/LF.TodoApp.EntityFrameworkCore.Tests.csproj --logger "trx;LogFileName=EntityFrameworkCoreTestResults.trx" --results-directory test/results
          echo "Tests have been executed successfully"

  build_and_upload: 
    name: Build and Upload to build Artifacts
    needs: test
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
 
    # Only build the images once and then promote them to each environment by their tags!

    # Build the Blazor UI Docker image
    - name: Build the Blazor UI Docker image
      run:  docker build . --file TodoApp/LF.TodoApp/aspnet-core/src/LF.TodoApp.Blazor/Dockerfile --tag blazor:${{ github.sha }} 

    # Build the Migrator Docker image
    - name: Build the Migrator Docker image
      run: docker build . --file TodoApp/LF.TodoApp/aspnet-core/src/LF.TodoApp.DbMigrator/Dockerfile --tag dbmigrator:${{ github.sha }} 

    # Save Docker image as a tar file so it can be used in the next job
    - name: Save Docker image
      run: |
        docker save blazor:${{ github.sha }} | gzip > blazor.tar.gz
        docker save dbmigrator:${{ github.sha }} | gzip > dbmigrator.tar.gz

    # Upload Docker image as a build artifact
    - name: Upload Docker image
      uses: actions/upload-artifact@v3.1.2
      with:
        name: docker-images
        path: |
          blazor.tar.gz
          dbmigrator.tar.gz

  push-docker-dev:
      name: Push Docker Images to ACR (Development)
      needs: build_and_upload
      if: github.ref == 'refs/heads/main'
      runs-on: ubuntu-latest
      steps:
        - uses: actions/checkout@v3

        - name: Download Docker image
          uses: actions/download-artifact@v2
          with:
            name: docker-images

        - name: Load Docker images
          run: |
            gunzip -c blazor.tar.gz | docker load
            gunzip -c dbmigrator.tar.gz | docker load

        - name: Login to Azure
          uses: azure/login@v1
          with:
            creds: ${{ secrets.DEV_AZURE_CREDENTIALS }}

        - name: Login to ACR (Development)
          run: |
            az acr login --name ${{ env.DEV_AZURE_CONTAINER_REGISTRY }}

        - name: Push Docker Images to ACR (Development)
          run: |
            docker tag blazor:${{ github.sha }} ${{ env.DEV_AZURE_CONTAINER_REGISTRY }}/blazor:${{ github.sha }}
            docker push ${{ env.DEV_AZURE_CONTAINER_REGISTRY }}/blazor:${{ github.sha }}

            docker tag dbmigrator:${{ github.sha }} ${{ env.DEV_AZURE_CONTAINER_REGISTRY }}/dbmigrator:${{ github.sha }}
            docker push ${{ env.DEV_AZURE_CONTAINER_REGISTRY }}/dbmigrator:${{ github.sha }}

        - name: Verify Images in ACR (Development)
          run: |
            blazor_image_check=$(az acr repository show-tags --name ${{ env.DEV_AZURE_CONTAINER_REGISTRY }} --repository blazor --output tsv | grep ${{ github.sha }})
            dbmigrator_image_check=$(az acr repository show-tags --name ${{ env.DEV_AZURE_CONTAINER_REGISTRY }} --repository dbmigrator --output tsv | grep ${{ github.sha }})
            if [[ -z "$blazor_image_check" ]]; then echo "Blazor image not found in ACR" && exit 1; fi
            if [[ -z "$dbmigrator_image_check" ]]; then echo "DBMigrator image not found in ACR" && exit 1; fi

  push-docker-prod:
        name: Push Docker Images to ACR (Development)
        needs: build_and_upload
        if: startsWith(github.ref, 'refs/tags/v') && github.event.base_ref == 'refs/heads/main'
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v3

            - name: Login to Azure
              uses: azure/login@v1
              with:
                creds: ${{ secrets.PROD_AZURE_CREDENTIALS }}

            - name: Login to ACR (Production)
              run: |
                az acr login --name ${{ env.PROD_AZURE_CONTAINER_REGISTRY }}

            - name: Push Docker Images to ACR (Production)
              run: |
                docker tag blazor:${{ github.sha }} ${{ env.PROD_AZURE_CONTAINER_REGISTRY }}/blazor:${{ github.sha }}
                docker push ${{ env.PROD_AZURE_CONTAINER_REGISTRY }}/blazor:${{ github.sha }}
        
                docker tag dbmigrator:${{ github.sha }} ${{ env.PROD_AZURE_CONTAINER_REGISTRY }}/dbmigrator:${{ github.sha }}
                docker push ${{ env.PROD_AZURE_CONTAINER_REGISTRY }}/dbmigrator:${{ github.sha }}

            - name: Verify Images in ACR (Production)
              run: |
                blazor_image_check=$(az acr repository show-tags --name ${{ env.PROD_AZURE_CONTAINER_REGISTRY }} --repository blazor --output tsv | grep ${{ github.sha }})
                dbmigrator_image_check=$(az acr repository show-tags --name ${{ env.PROD_AZURE_CONTAINER_REGISTRY }} --repository dbmigrator --output tsv | grep ${{ github.sha }})
                if [[ -z "$blazor_image_check" ]]; then echo "Blazor image not found in ACR" && exit 1; fi
                if [[ -z "$dbmigrator_image_check" ]]; then echo "DBMigrator image not found in ACR" && exit 1; fi

  create-infrastructure-dev:
     name: Create Infrastructure (Development)
     needs: build_and_upload
     if: github.ref == 'refs/heads/main'
     runs-on: ubuntu-latest  
     steps:
     - uses: actions/checkout@v3
     
     - name: Login to Azure
       uses: azure/login@v1
       with:
        creds: ${{ secrets.DEV_AZURE_CREDENTIALS }}
      
     - name: Create Resource Group (Development)
       run: |
         az group create --name ${{ env.DEV_RESOURCE_GROUP }} --location ${{ env.DEV_LOCATION }}
     
     - name: Deploy Infrastructure (Development)
       uses: azure/arm-deploy@v1
       with:
        subscriptionId: ${{ secrets.DEV_AZURE_SUBSCRIPTION_ID }}
        resourceGroupName: ${{ env.DEV_RESOURCE_GROUP }}
        template: ${{ env.BICEP_FILE_PATH }} 
        parameters: >-
             webAppName=${{ env.DEV_AZURE_WEBAPP_NAME }}
             aspnetcoreEnvironment=Dev
             location=${{ env.DEV_LOCATION }}
             appServicePlanSkuName=${{ env.DEV_APPSERVICE_PLAN }} 
             netFrameworkVersion=v7.0
             postgresFlexibleServersName=${{ env.DEV_POSTGRESQL_SERVER_NAME }}
             postgresqlAdminLogin=${{ env.DEV_POSTGRESQL_ADMIN_LOGIN }}
             postgresqlAdminPassword=${{ env.DEV_POSTGRESQL_ADMIN_PASSWORD }}
             postgresFlexibleServersSkuTier=${{env.DEV_POSTGRESQL_SKU_TIER }}
             postgresFlexibleServersSkuName=${{env.DEV_POSTGRESQL_SKU_NAME }}
             postgresqlServerStorage=${{ env.DEV_POSTGRESQL_STORAGE }}
             databaseName=${{ env.DEV_POSTGRESQL_DATABASE_NAME }}

  create-infrastructure-prod:
    name: Create Infrastructure (Production)
    needs: build_and_upload
    if: startsWith(github.ref, 'refs/tags/v') && github.event.base_ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
  
    - name: Login to Azure
      uses: azure/login@v1
      with:
        creds: ${{ secrets.PROD_AZURE_CREDENTIALS }}
      
    - name: Create Resource Group (Production)
      run: |
        az group create --name ${{ env.PROD_RESOURCE_GROUP }} --location ${{ env.PROD_LOCATION }}
    
    - name: Deploy Infrastructure (Production)
      uses: azure/arm-deploy@v1
      with:
        subscriptionId: ${{ secrets.PROD_AZURE_SUBSCRIPTION_ID }}
        resourceGroupName: ${{ env.PROD_RESOURCE_GROUP }}
        template: ${{ env.BICEP_FILE_PATH }} 
        parameters: >-
          webAppName=${{ env.PROD_AZURE_WEBAPP_NAME }}
          aspnetcoreEnvironment=Production
          location=${{ env.PROD_LOCATION }}
          appServicePlanSkuName=${{ env.PROD_APPSERVICE_PLAN }} 
          netFrameworkVersion=v7.0
          postgresFlexibleServersName=${{ env.PROD_POSTGRESQL_SERVER_NAME }}
          postgresqlAdminLogin=${{ env.PROD_POSTGRESQL_ADMIN_LOGIN }}
          postgresqlAdminPassword=${{ env.PROD_POSTGRESQL_ADMIN_PASSWORD }}
          postgresFlexibleServersSkuTier= ${{env.PROD_POSTGRESQL_SKU_TIER }}
          postgresFlexibleServersSkuName= ${{env.PROD_POSTGRESQL_SKU_NAME }}
          postgresqlServerStorage=${{ env.PROD_POSTGRESQL_STORAGE }}
          databaseName=${{ env.PROD_POSTGRESQL_DATABASE_NAME }}

  deploy-development:
    name: Deploy to Development
    needs: [build_and_upload, create-infrastructure-dev,push-docker-dev]
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3

    - name: Login to Azure
      uses: azure/login@v1
      with:
        creds: ${{ secrets.DEV_AZURE_CREDENTIALS }}

    - name: Login to ACR (Development)
      run: az acr login --name ${{ env.DEV_AZURE_CONTAINER_REGISTRY }}

    - name: Deploy Blazor Docker Image to Azure Web App (Development)
      run: |
        az webapp config container set \
        --name ${{ env.DEV_AZURE_WEBAPP_NAME }} \
        --resource-group ${{ env.DEV_RESOURCE_GROUP }} \
        --docker-custom-image-name ${{ env.DEV_AZURE_CONTAINER_REGISTRY }}/blazor:${{ github.sha }} \
        --docker-registry-server-url https://${{ env.DEV_AZURE_CONTAINER_REGISTRY }}

    - name: Run Migrations (Development)
      run: |
        docker pull ${{ env.DEV_AZURE_CONTAINER_REGISTRY }}/dbmigrator:${{ github.sha }}

        # Create a connection string for the database and pass it to the container
        connection_string="Server=${{ env.DEV_POSTGRESQL_SERVER_NAME }}.postgres.database.azure.com;Database=${{ env.DEV_POSTGRESQL_DATABASE_NAME }};Port=5432;User Id=${{ env.DEV_POSTGRESQL_ADMIN_LOGIN }};Password=${{ env.DEV_POSTGRESQL_ADMIN_PASSWORD }};Ssl Mode=Require;Trust Server Certificate=true;"
   
        # Run Migrations 
        docker run --rm \
        -e ConnectionStrings:Default="$connection_string" \
        ${{ env.DEV_AZURE_CONTAINER_REGISTRY }}/dbmigrator:${{ github.sha }}

  deploy-production:
    name: Deploy to Production
    needs: [build_and_upload, create-infrastructure-prod, push-docker-prod]
    if: startsWith(github.ref, 'refs/tags/v') && github.event.base_ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3

    - name: Login to Azure
      uses: azure/login@v1
      with:
        creds: ${{ secrets.PROD_AZURE_CREDENTIALS }}

    - name: Login to ACR (Production)
      run: az acr login --name ${{ env.PROD_AZURE_CONTAINER_REGISTRY }}

    - name: Check Docker Images in ACR (Production)
      run: |
        blazor_manifests=$(az acr repository show-manifests --name ${{ env.PROD_AZURE_CONTAINER_REGISTRY }} --repository blazor --orderby time_desc)
        dbmigrator_manifests=$(az acr repository show-manifests --name ${{ env.PROD_AZURE_CONTAINER_REGISTRY }} --repository dbmigrator --orderby time_desc)

        if [[ $blazor_manifests != *"${{ github.sha }}"* || $dbmigrator_manifests != *"${{ github.sha }}"* ]]; then
          echo "Docker images not found in ACR (Production)"
          exit 1
        fi

    - name: Deploy Blazor Docker Image to Azure Web App (Production)
      run: |
        az webapp config container set \
        --name ${{ env.PROD_AZURE_WEBAPP_NAME }} \
        --resource-group ${{ env.PROD_RESOURCE_GROUP }} \
        --docker-custom-image-name ${{ env.PROD_AZURE_CONTAINER_REGISTRY }}/blazor:${{ github.sha }} \
        --docker-registry-server-url https://${{ env.PROD_AZURE_CONTAINER_REGISTRY }}

    - name: Run Migrations (Development)
      run: |
        docker pull ${{ env.PROD_AZURE_CONTAINER_REGISTRY }}/dbmigrator:${{ github.sha }}

        connection_string="Server=${{ env.PROD_POSTGRESQL_SERVER_NAME }}.postgres.database.azure.com;Database=${{ env.PROD_POSTGRESQL_DATABASE_NAME }};Port=5432;User Id=${{ env.PROD_POSTGRESQL_ADMIN_LOGIN }};Password=${{ env.PROD_POSTGRESQL_ADMIN_PASSWORD }};Ssl Mode=VerifyFull;"
   
        # Run Migrations 
        docker run --rm \
        -e ConnectionStrings:Default="$connection_string" \
        ${{ env.PROD_AZURE_CONTAINER_REGISTRY }}/dbmigrator:${{ github.sha }}

Add the secrets to GitHub

You need to add the following values

secrets.DEV_POSTGRESQL_ADMIN_LOGIN 
secrets.DEV_POSTGRESQL_ADMIN_PASSWORD 
secrets.DEV_AZURE_SUBSCRIPTION_ID
secrets.DEV_AZURE_CREDENTIALS 

Screenshot-2023-08-06-111738

But to create the DEV_AZURE_CREDENTIALS you need to run the following command

az ad sp create-for-rbac --name {yourServicePrincipalName} --role contributor --scopes /subscriptions/{yourSubscriptionId}/resourceGroups/{yourResourceGroup}

It will give you this

{
  "appId": "a487e0c1-82af-47d9-9a0b-af184eb87646d",
  "displayName": "myServicePrincipal",
  "name": "http://myServicePrincipal",
  "password": "879sd8-3435t-3478-58394-8893",
  "tenant": "ad8c9dd3-6d4b-45c3-b9c6-5c4b1f8e5eb6"
}

that you should copy to this format

{
  "clientId": "a487e0c1-82af-47d9-9a0b-af184eb87646d",
  "clientSecret": "879sd8-3435t-3478-58394-8893",
  "subscriptionId": "yourSubscriptionId",
  "tenantId": "ad8c9dd3-6d4b-45c3-b9c6-5c4b1f8e5eb6"
}

and that JSON you should save into DEV_AZURE_CREDENTIALS in Github

Create a Main.bicep

Now we need to create the infrastructure file so navigate to TodoApp/LF.TodoApp/aspnet-core/ and create Main.bicep and paste in the following code.

This environment setup is just the most simple one just to get you started with something. Its not thought to be a super secure production environment! Please feel free to add and change this code as you like. I would love to see some gists please.

// Parameters START
@description('Name of the web application')
param webAppName string

@description('ASP.NET Core environment')
param aspnetcoreEnvironment string

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

@description('The SKU of App Service Plan')
@allowed(['B1', 'B2', 'B3', 'F1'])
param appServicePlanSkuName string = 'B1'

@description('The .NET Framework version for the web app')
@allowed(['v7.0','v8.0'])
param netFrameworkVersion string = 'v7.0'

@description('The tier of the particular SKU, e.g. Burstable')
@allowed(['burstable', 'generalpurpose', 'memoryoptimized'])
param postgresFlexibleServersSkuTier string = 'burstable'

@description('The SKU of the PostgreSQL server')
@allowed(['Standard_B1ms']) // Add more when going to production
param postgresFlexibleServersSkuName  string = 'Standard_B1ms'

@description('The version of a PostgreSQL server')
@allowed([ '13' ])
param postgresFlexibleServersversion string = '13'

@description('Name of the PostgreSQL server')
param postgresFlexibleServersName string

@description('Admin login for the PostgreSQL server')
param postgresqlAdminLogin string

@description('Admin password for the PostgreSQL server')
@secure()
param postgresqlAdminPassword string

@description('The mode to create a new PostgreSQL server')
@allowed([  'Create', 'Default', 'PointInTimeRestore', 'Update' ])
param createMode string = 'Default'

@description('The size of storage for the PostgreSQL server')
@allowed([32,64,128,256,512,1024,2084])
param postgresqlServerStorage int = 32 

@description('Name of the PostgreSQL database')
param databaseName string

// Parameters END

resource appServicePlan 'Microsoft.Web/serverfarms@2022-03-01' = {
  name: '${webAppName}serviceplan'
  location: location
  kind: 'linux'
  properties: {
    reserved: true
  }
  sku: {
    name: appServicePlanSkuName
  }
}

//TODO: Make this work with KeyVault so we don´t have the password in plain text
@description('Azure Postgresql connection string')
var postgresqlConnection = 'Server=${postgresFlexibleServersName}.postgres.database.azure.com;Database=${databaseName};Port=5432;User Id=${postgresqlAdminLogin};Password=${postgresqlAdminPassword};Ssl Mode=Require;Trust Server Certificate=true;'

resource postgresFlexibleServers 'Microsoft.DBforPostgreSQL/flexibleServers@2022-12-01' = {
  name: postgresFlexibleServersName
  location: location
  sku: {
    name: postgresFlexibleServersSkuName
    tier: postgresFlexibleServersSkuTier
  }
  properties: {
    administratorLogin: postgresqlAdminLogin
    administratorLoginPassword: postgresqlAdminPassword
    createMode:createMode
    storage: {  
      storageSizeGB: postgresqlServerStorage
    }
    version: postgresFlexibleServersversion
  }
}

// Here is a list of GitHub IP´s that can be added https://api.github.com/meta
var GitHubIps = ['192.30.252.0','185.199.108.0',  '140.82.112.0',  '143.55.64.0', '20.201.28.148', '20.205.243.168',  '20.87.225.211',  '20.248.137.49',  '20.207.73.85',  '20.27.177.116',  '20.200.245.245','20.233.54.49']

var firewallRuleNames = [for i in range(0, length(GitHubIps)): 'github-${postgresFlexibleServersName}-firewall-${i}']

@description('Firewall rules to allow GitHub Actions to access the PostgreSQL server to run the migrations')
resource firewallRules 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-03-01-preview' = [for i in range(0, length(GitHubIps)): {
  name: firewallRuleNames[i]
  parent: postgresFlexibleServers
  properties: {
    startIpAddress: GitHubIps[i]
    endIpAddress: GitHubIps[i]
  }
}]

@description('Firewall rule to allow Azure Services to access the PostgreSQL server')
resource postgresFlexibleServersFirewallRule 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2022-12-01' = {
  name: 'AllowAzureServices'
  parent: postgresFlexibleServers
  properties: {
    startIpAddress: '0.0.0.0'
    endIpAddress: '0.0.0.0'
  }
}


@description('Azure AppService')
resource webApplication 'Microsoft.Web/sites@2022-09-01' = {
  name: webAppName 
  kind: 'app'
  location: location
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    serverFarmId: appServicePlan.id
    siteConfig: {
      netFrameworkVersion: netFrameworkVersion
      ftpsState: 'FtpsOnly'
      connectionStrings: [
        {
          name: 'ConnectionStrings__Default'
          connectionString: postgresqlConnection
          type: 'PostgreSQL'
        }
      ]
      appSettings: [
        {
          name: 'ASPNETCORE_ENVIRONMENT'
          value: aspnetcoreEnvironment
        }
      ]
    }
    httpsOnly: true
    publicNetworkAccess: 'Enabled'
  }
}


// Output help information
output appServicePlanOutput string = appServicePlan.id
output postgresFlexibleServersOutput string = postgresFlexibleServers.id
output postgresqlServerOutput string = postgresFlexibleServers.id 
output storageSizeMessage string = 'The storage size is: ${postgresqlServerStorage} GB'
output webApplicationOutput string = webApplication.properties.defaultHostName 

There are few things in there to take note of.

Firewall rules: I had issues being able to connect from Github to the PostgreSQL and needed to add these rules. I tried lots of other suggestions (actions and identity etc.) but none of them worked so if you know of a better way please let me know.

Plain text connection string: Since I just wanted to get my code up and running on dev so I could start to code I wasn´t worried about this now. I will update this code to point to Azure KeyVault for the user/pass in the connection string before long.

Add a appsettings.Dev.json file

This is the file used in your dev environment and the Dev part comes from the aspnetcoreEnvironment passed into the bicep file that sets the ASPNETCORE_ENVIRONMENT that will read from this file.

{
  "App": {
    "SelfUrl": "https://td-d-TodoApp.azurewebsites.net",
    "RedirectAllowedUrls": "https://td-d-TodoApp.azurewebsites.net",
    "DisablePII": "false"
  },
  "ConnectionStrings": {
    "Default": "Server=todo-d-ser.postgres.database.azure.com;Database=TodoAppdb;Port=5432;User Id=YourAdmin;Password=YourPassword;Ssl Mode=Require;Trust Server Certificate=true;"
  },
  "AuthServer": {
    "Authority": "https://td-d-TodoApp.azurewebsites.net",
    "RequireHttpsMetadata": "true"
  },
  "StringEncryption": {
    "DefaultPassPhrase": "ramdom text"
  },
  "MyAppCertificate": {
    "X590": "some GUID is good"
  }
}

Docker file update

Update your Dockerfile in the Blazor Server project like this with information from Anto Subash - Abp Dockerfile blog post. If you don't you will have a 500 error in the service.

# Base Image
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
ENV ASPNETCORE_URLS=http://+:80

# Build Image
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src

# Install Node.js
ENV NODE_VERSION 16.13.0
ENV NODE_DOWNLOAD_URL https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.gz
ENV NODE_DOWNLOAD_SHA 589b7e7eb22f8358797a2c14a0bd865459d0b44458b8f05d2721294dacc7f734
RUN curl -SL "$NODE_DOWNLOAD_URL" --output nodejs.tar.gz \
    && echo "$NODE_DOWNLOAD_SHA nodejs.tar.gz" | sha256sum -c - \
    && tar -xzf "nodejs.tar.gz" -C /usr/local --strip-components=1 \
    && rm nodejs.tar.gz \
    && ln -s /usr/local/bin/node /usr/local/bin/nodejs

# Install gnupg for verifying signatures
RUN apt update && apt -y install gnupg

# Install Yarn
ENV YARN_VERSION 1.22.15
RUN set -ex \
  && wget -qO- https://dl.yarnpkg.com/debian/pubkey.gpg | gpg --import \
  && curl -fSLO --compressed "https://yarnpkg.com/downloads/$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" \
  && curl -fSLO --compressed "https://yarnpkg.com/downloads/$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz.asc" \
  && gpg --batch --verify yarn-v$YARN_VERSION.tar.gz.asc yarn-v$YARN_VERSION.tar.gz \
  && mkdir -p /opt/yarn \
  && tar -xzf yarn-v$YARN_VERSION.tar.gz -C /opt/yarn --strip-components=1 \
  && ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \
  && ln -s /opt/yarn/bin/yarn /usr/local/bin/yarnpkg \
  && rm yarn-v$YARN_VERSION.tar.gz.asc yarn-v$YARN_VERSION.tar.gz

# Copy project files
COPY ["TodoApp/TD.TodoApp/aspnet-core/NuGet.Config", "."]
COPY ["TodoApp/TD.TodoApp/aspnet-core/src/TD.TodoApp.Blazor/TD.TodoApp.Blazor.csproj", "src/TD.TodoApp.Blazor/"]
COPY ["TodoApp/TD.TodoApp/aspnet-core/src/TD.TodoApp.Application/TD.TodoApp.Application.csproj", "src/TD.TodoApp.Application/"]
COPY ["TodoApp/TD.TodoApp/aspnet-core/src/TD.TodoApp.Domain/TD.TodoApp.Domain.csproj", "src/TD.TodoApp.Domain/"]
COPY ["TodoApp/TD.TodoApp/aspnet-core/src/TD.TodoApp.Domain.Shared/TD.TodoApp.Domain.Shared.csproj", "src/TD.TodoApp.Domain.Shared/"]
COPY ["TodoApp/TD.TodoApp/aspnet-core/src/TD.TodoApp.Application.Contracts/TD.TodoApp.Application.Contracts.csproj", "src/TD.TodoApp.Application.Contracts/"]
COPY ["TodoApp/TD.TodoApp/aspnet-core/src/TD.TodoApp.HttpApi/TD.TodoApp.HttpApi.csproj", "src/TD.TodoApp.HttpApi/"]
COPY ["TodoApp/TD.TodoApp/aspnet-core/src/TD.TodoApp.EntityFrameworkCore/TD.TodoApp.EntityFrameworkCore.csproj", "src/TD.TodoApp.EntityFrameworkCore/"]

# Restore and Install ABP CLI
RUN dotnet restore "src/TD.TodoApp.Blazor/TD.TodoApp.Blazor.csproj"
RUN dotnet tool install -g Volo.Abp.Cli

# Set environment path for ABP CLI
ENV PATH="${PATH}:/root/.dotnet/tools"

# Copy remaining files and install ABP libraries
COPY . .
WORKDIR "/src/TodoApp/TD.TodoApp/aspnet-core/src/TD.TodoApp.Blazor"
RUN abp install-libs

# Build the project
RUN dotnet build "TD.TodoApp.Blazor.csproj" -c Release -o /app/build

# Publish the project
FROM build AS publish
RUN dotnet publish "TD.TodoApp.Blazor.csproj" -c Release -o /app/publish /p:UseAppHost=false

# Final Image
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "TD.TodoApp.Blazor.dll"]

Code changes

500.30 or pfx error

If you get this error you might need to follow this blog-post Deploying abp.io to an Azure AppService

Go to your Blazor project TodoAppBlazorModule.cs file and paste in the following code over GetSigningCertificate().

private X509Certificate2 GetSigningCertificate(
IWebHostEnvironment hostingEnv,
IConfiguration configuration)
{
var fileName = $"cert-signing.pfx";
var passPhrase = configuration["MyAppCertificate:X590:PassPhrase"];
var file = Path.Combine(hostingEnv.ContentRootPath, fileName);
if (File.Exists(file))
{
    var created = File.GetCreationTime(file);
    var days = (DateTime.Now - created).TotalDays;
    if (days > 180)
        File.Delete(file);
    else
        return new X509Certificate2(file, passPhrase,
                     X509KeyStorageFlags.MachineKeySet);
}

// file doesn't exist or was deleted because it expired
using var algorithm = RSA.Create(keySizeInBits: 2048);
var subject = new X500DistinguishedName("CN=LawFull.ai Signing Certificate");
var request = new CertificateRequest(subject, algorithm,
                    HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509KeyUsageExtension(
                    X509KeyUsageFlags.DigitalSignature, critical: true));
var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow,
                    DateTimeOffset.UtcNow.AddYears(2));
File.WriteAllBytes(file, certificate.Export(X509ContentType.Pfx, string.Empty));
return new X509Certificate2(file, passPhrase,
                    X509KeyStorageFlags.MachineKeySet);
}

private X509Certificate2 GetEncryptionCertificate(
IWebHostEnvironment hostingEnv,
IConfiguration configuration)
{
var fileName = $"cert-encryption.pfx";
var passPhrase = configuration["MyAppCertificate:X590:PassPhrase"];
    var file = Path.Combine(hostingEnv.ContentRootPath, fileName);
if (File.Exists(file))
{
    var created = File.GetCreationTime(file);
    var days = (DateTime.Now - created).TotalDays;
    if (days > 180)
        File.Delete(file);
    else
        return new X509Certificate2(file, passPhrase,
                        X509KeyStorageFlags.MachineKeySet);
}

// file doesn't exist or was deleted because it expired
using var algorithm = RSA.Create(keySizeInBits: 2048);
var subject = new X500DistinguishedName("CN=LawFull.ai Encryption Certificate");
var request = new CertificateRequest(subject, algorithm,
                    HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(new X509KeyUsageExtension(
                    X509KeyUsageFlags.KeyEncipherment, critical: true));
var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow,
                    DateTimeOffset.UtcNow.AddYears(2));
File.WriteAllBytes(file, certificate.Export(X509ContentType.Pfx, string.Empty));
return new X509Certificate2(file, passPhrase, X509KeyStorageFlags.MachineKeySet);
}

you will also need to update its usage at the top

PreConfigure<OpenIddictServerBuilder>(builder =>
{
    // In production, it is recommended to use two RSA certificates, 
    // one for encryption, one for signing.
    builder.AddSigningCertificate(GetSigningCertificate(hostingEnvironment, configuration));
    builder.AddEncryptionCertificate(GetEncryptionCertificate(hostingEnvironment, configuration));
    builder.SetIssuer(new Uri(configuration["AuthServer:Authority"]!));
});

Update the DBMigrator to accept env ConnectionString

I could not get the "Run Migrations" step to work in the yml by passing in the connectionStrings:Default value until I added the following code to DbMigratorHostedService.cs

public async Task StartAsync(CancellationToken cancellationToken)
{
    var envConnectionString = Environment.GetEnvironmentVariable("ConnectionStrings:Default");
    if (!string.IsNullOrEmpty(envConnectionString))
    {
        _configuration["ConnectionStrings:Default"] = envConnectionString;
        Log.Logger.Information("Using ConnectionStrings:Default from environmental variable");
 
 // rest of code 
}        

It would be great if somebody could share with me a solution to this.

Todo´s

ConnectionString from appsettings.json issue

There is one issue I'm still having problem with getting the AppService to read the ConnectionString from the Azure UI. It seems to want to read from the appsettings.Dev.json file and not allow the UI to override it as it should do!

I will figure this out (or somebody of you will let me know why it didn't work).

Add keyVault support for ConnectionString

Now the bicep code puts the connectionString into the UI in clear text but should add it with KeyVault connection. So what needs to be done is to add KeyVault to the bicep and then point the connectionString to it so it.

Azure Developer CLI (azd) for abp.io?

I wish somebody would create a AZD Template and share it with us. Here is the documentation to create these templates.

It would be super helpful for us to have many people working on these templates together for all the different kinds of abp.io projects.

Final words

I just wanted to share this with you quickly and not waist time making it "perfect". I will 100% iterate this code as I go further and will probably update this code if it changes allot.

But please let me know if you have some issues or if you have improvements or tips for me.