{"id":15426461,"url":"https://github.com/paolosalvatori/web-app-redis-sql-db","last_synced_at":"2025-10-03T18:26:41.701Z","repository":{"id":84678362,"uuid":"351762313","full_name":"paolosalvatori/web-app-redis-sql-db","owner":"paolosalvatori","description":"This sample shows how to configure an Azure App Service to access Azure Cache for Redis and Azure SQL Database via regional VET integration and Private Endpoints","archived":false,"fork":false,"pushed_at":"2021-05-06T14:58:35.000Z","size":7860,"stargazers_count":9,"open_issues_count":0,"forks_count":2,"subscribers_count":4,"default_branch":"master","last_synced_at":"2025-02-24T00:41:24.285Z","etag":null,"topics":["azure","azure-app-service","azure-application-insights","azure-cache-redis","azure-log-analytics","azure-monitor","azure-private-endpoints","azure-sql-database","azure-storage-blob","azure-virtual-networks"],"latest_commit_sha":null,"homepage":"","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/paolosalvatori.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2021-03-26T11:40:47.000Z","updated_at":"2024-10-26T00:11:10.000Z","dependencies_parsed_at":null,"dependency_job_id":"b5fb6fc6-fbbc-4f62-8490-27488e5439da","html_url":"https://github.com/paolosalvatori/web-app-redis-sql-db","commit_stats":{"total_commits":22,"total_committers":1,"mean_commits":22.0,"dds":0.0,"last_synced_commit":"d73029cc711df8f581d0cfe042ae87e85b979052"},"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paolosalvatori%2Fweb-app-redis-sql-db","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paolosalvatori%2Fweb-app-redis-sql-db/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paolosalvatori%2Fweb-app-redis-sql-db/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/paolosalvatori%2Fweb-app-redis-sql-db/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/paolosalvatori","download_url":"https://codeload.github.com/paolosalvatori/web-app-redis-sql-db/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":241487339,"owners_count":19970824,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["azure","azure-app-service","azure-application-insights","azure-cache-redis","azure-log-analytics","azure-monitor","azure-private-endpoints","azure-sql-database","azure-storage-blob","azure-virtual-networks"],"created_at":"2024-10-01T17:56:25.382Z","updated_at":"2025-10-03T18:26:41.592Z","avatar_url":"https://github.com/paolosalvatori.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"---\nproducts: azure,  aspnet, azure-application-insights, azure-app-service, azure-blob-storage, azure-storage-accounts, azure-sql, azure-cache-for-redis, azure-database, azure-web-app, azure-log-analytics, azure-nat-gateway, azure-virtual-machines, vs-code\n---\n\n# How to configure an Azure Web App to call Azure Cache for Redis and Azure SQL Database via Private Endpoints\n\nThis sample shows how to deploy an infrastructure and network topology on Azure where an ASP.NET Core web application hosted by an [Azure App Service](https://docs.microsoft.com/en-us/azure/app-service/) accesses data from [Azure Cache for Redis](https://docs.microsoft.com/en-us/azure/azure-cache-for-redis/cache-overview) and [Azure SQL Database](https://docs.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview) using [Azure Private Endpoints](https://docs.microsoft.com/azure/private-link/private-endpoint-overview). The Azure Web App is hosted in a [Standard, Premium, PremiumV2, PremiumV3](https://docs.microsoft.com/en-us/azure/app-service/overview-hosting-plans) with [Regional VNET Integration](https://docs.microsoft.com/en-us/azure/app-service/web-sites-integrate-with-vnet#regional-vnet-integration).\nPrivate endpoints are fully supported also by the Standard tier of [Azure Cache for Redis](https://docs.microsoft.com/en-us/azure/azure-cache-for-redis/cache-private-link). However, to use private endpoints, an Azure Cache for Redis instance needs to have been created after July 28th, 2020. Currently, zone redundancy, portal console support, and persistence to firewall storage accounts are not supported.\n\nThis sample also shows  how to:\n\n- use a system-assigned managed identity to let the Web App access secrets from Azure Key Vault\n- deploy an ASP.NET Core application to an Azure App Service using a GitHub Actions workflow\n- disable the public network access from the internet to all the managed services used by the application:\n\n  - Azure Blob Storage Account\n  - Azure Key Vault\n  - Azure Cache for Redis\n  - Azure SQL Database\n  - Azure Application Insights\n\nAs an alternative solution, this sample also shows how to deploy Premium Azure Cache for Redis in a virtual network. When an Azure Cache for Redis instance is configured with a virtual network, it isn't publicly addressable and can only be accessed from virtual machines and applications within the virtual network.\n\nFor more information, see:\n\n- [Azure Private Link for Azure SQL Database and Azure Synapse Analytics](https://docs.microsoft.com/en-us/azure/azure-sql/database/private-endpoint-overview)\n- [Web app private connectivity to Azure SQL database](https://docs.microsoft.com/en-us/azure/architecture/example-scenario/private-web-app/private-web-app)\n- [Azure Cache for Redis with Azure Private Link](https://docs.microsoft.com/en-us/azure/azure-cache-for-redis/cache-private-link)\n- [Configure virtual network support for a Premium Azure Cache for Redis instance](https://docs.microsoft.com/en-us/azure/azure-cache-for-redis/cache-how-to-premium-vnet)\n\nIn addition, Azure Web Apps can be configured to be called via a private IP address by applications located in the same virtual network, or in a peered network, or on-premises via ExpressRoute or a S2S VPN. For more information, see:\n\n- [Using Private Endpoints for Azure Web App](https://docs.microsoft.com/en-us/azure/app-service/networking/private-endpoint).\n- [Create an App Service app and deploy a private endpoint by using an Azure Resource Manager template](https://docs.microsoft.com/en-us/azure/app-service/scripts/template-deploy-private-endpoint).\n\n## Deploy to Azure\n\nYou can use the following button to deploy the demo to your Azure subscription:\n\nAzure Cache for Redis via Private Endpoints\n\n[![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fpaolosalvatori%2Fweb-app-redis-sql-db%2Fmaster%2Ftemplates%2Fazuredeploy.endpoint.json%3Ftoken%3DAAIW4AOWATWNQLL2JZKDBAK63EOOU)\n\nAzure Cache for Redis in a virtual network\n\n[![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fpaolosalvatori%2Fweb-app-redis-sql-db%2Fmaster%2Ftemplates%2Fazuredeploy.endpoint.json%3Ftoken%3DAAIW4AOWATWNQLL2JZKDBAK63EOOU)\n\n## Architecture\n\nThe following picture shows the architecture and network topology of the first solution where a Standard Azure Cache for Redis is accessed by an Azure Web App via [Regional VNET Integration](https://docs.microsoft.com/en-us/azure/app-service/web-sites-integrate-with-vnet#regional-vnet-integration) and [Azure Private Endpoints](https://docs.microsoft.com/azure/private-link/private-endpoint-overview).\n\n![Architecture with Azure Cache for Redis accessed via Private Endpoint](images/redis-cache-private-endpoint.png)\n\nThe ARM template deploys the following resources:\n\n- Virtual Network: this virtual network is composed of the following subnets:\n  - **WebAppSubnet**: this subnet is used for the regional VNET integration with the Azure Web App app hosted by a Premium Plan. For more information, see [Using Private Endpoints for Azure Web App](https://docs.microsoft.com/en-us/azure/app-service/networking/private-endpoint).\n  - **PrivateEndpointSubnet**: hosts the private endpoints used by the application.\n  - **VirtualMachineSubnet**: hosts the jumpbox virtual machine and any additional virtual machine used by the solution.\n  - **AzureBastionSubnet**: hosts Azure Bastion. For more information, see [Working with NSG access and Azure Bastion](https://docs.microsoft.com/en-us/azure/bastion/bastion-nsg).\n- Network Security Group: this resource contains an inbound rule to allow access to the jumpbox virtual machine on port 3389 (RDP)\n- A Windows 10 virtual machine. This virtual machine can be used as jumpbox virtual machine to simulate a real application and send requests to the Azure Web Apps exposed via [Azure Private Link](https://docs.microsoft.com/en-us/azure/private-link/private-link-overview).\n- A Public IP for Azure Bastion\n- Azure Bastion is used to access the jumpbox virtual machine from the Azure Portal via RDP. For more information, see [What is Azure Bastion?](https://docs.microsoft.com/en-us/azure/bastion/bastion-overview).\n- An ADLS Gen 2 storage account used to store the boot diagnostics logs of the virtual machine as blobs\n- A [Standard, Premium, PremiumV2, PremiumV3](https://docs.microsoft.com/en-us/azure/app-service/overview-hosting-plans) hosting plan that supports [Regional VNET Integration](https://docs.microsoft.com/en-us/azure/app-service/web-sites-integrate-with-vnet#regional-vnet-integration)\n- An Azure App Service containing an ASP.NET Core application that uses a system-assigned managed identity to read settings from Key vault. The web site is a single page application that stores data in [Azure SQL Database](https://docs.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview) and caches items in Azure Cache for Redis.\n- An Application Insights resource used by the Azure Web Apps app to store logs, traces, requests, exceptions, and metrics. For more information, see [Web application monitoring on Azure](https://docs.microsoft.com/en-us/azure/architecture/reference-architectures/app-service-web-app/app-monitoring).\n- An Azure SQL Server and [Azure SQL Database](https://docs.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview) hosting the ProductDB relational database used by the Web App.\n- An Azure Key Vault used to store the following application settings. These settings are automtically created by the ARM template as secrets in Azure Key Vault:\n\n  - Azure Cache for Redis connection string\n  - Azure SQL Database connection string\n  - Application Insights Instrumentation Key\n\n- A private endpoint to the:\n\n  - Azure Blob storage account (boot diagnostics logs)\n  - Azure Cache for Redis\n  - Azure SQL Database\n  - Azure Key Vault\n\n- A Private DNS Zone Group to link each private endpoint with the corresponding Private DNS Zone.\n- The NIC used by the jumpbox virtual machine and for each private endpoint.\n- A Log Analytics workspace used to monitor the health status of the services such as the hosting plan or NSG.\n- A Private DNS Zone for Azure Blob Storage Account private endpoint (privatelink.blob.core.windows.net)\n- A Private DNS Zone for Azure Cache for Redis private endpoint (privatelink.redis.cache.windows.net)\n- A Private DNS Zone for Azure SQL Database private endpoint (privatelink.database.windows.net)\n- A Private DNS Zone for Azure Key Vault private endpoint (privatelink.vaultcore.azure.net)\n\nThe following picture shows the architecture and network topology of the first solution where a Standard Azure Cache for Redis is accessed by an Azure Web App via [Regional VNET Integration](https://docs.microsoft.com/en-us/azure/app-service/web-sites-integrate-with-vnet#regional-vnet-integration) and [Azure Private Endpoints](https://docs.microsoft.com/azure/private-link/private-endpoint-overview).\n\n![Architecture with Azure Cache for Redis accessed in a VNET](images/redis-cache-in-a-vnet.png)\n\nThe ARM template deploys the following resources:\n\n- Virtual Network: this virtual network is composed of the following subnets:\n  - **WebAppSubnet**: this subnet is used for the regional VNET integration with the Azure Web App app hosted by a Premium Plan. For more information, see [Using Private Endpoints for Azure Web App](https://docs.microsoft.com/en-us/azure/app-service/networking/private-endpoint).\n  - **PrivateEndpointSubnet**: hosts the private endpoints used by the application.\n  - **VirtualMachineSubnet**: hosts the jumpbox virtual machine and any additional virtual machine used by the solution.\n  - **AzureBastionSubnet**: hosts Azure Bastion. For more information, see [Working with NSG access and Azure Bastion](https://docs.microsoft.com/en-us/azure/bastion/bastion-nsg).\n  - **RedisCacheSubnet**: hosts the Premium Azure Cache for Redis\n- Network Security Group: this resource contains an inbound rule to allow access to the jumpbox virtual machine on port 3389 (RDP)\n- A Windows 10 virtual machine. This virtual machine can be used as jumpbox virtual machine to simulate a real application and send requests to the Azure Web Apps exposed via [Azure Private Link](https://docs.microsoft.com/en-us/azure/private-link/private-link-overview).\n- Azure Bastion is used to access the jumpbox virtual machine from the Azure Portal via RDP. For more information, see [What is Azure Bastion?](https://docs.microsoft.com/en-us/azure/bastion/bastion-overview).\n- An ADLS Gen 2 storage account used to store the boot diagnostics logs of the virtual machine as blobs\n- A [Standard, Premium, PremiumV2, PremiumV3](https://docs.microsoft.com/en-us/azure/app-service/overview-hosting-plans) hosting plan that supports [Regional VNET Integration](https://docs.microsoft.com/en-us/azure/app-service/web-sites-integrate-with-vnet#regional-vnet-integration)\n- An Azure App Service containing an ASP.NET Core application that uses a system-assigned managed identity to read settings from Key vault. The web site is a single page application that stores data in [Azure SQL Database](https://docs.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview) and caches items in Azure Cache for Redis.\n- An Application Insights resource used by the Azure Web Apps app to store logs, traces, requests, exceptions, and metrics. For more information, see [Web application monitoring on Azure](https://docs.microsoft.com/en-us/azure/architecture/reference-architectures/app-service-web-app/app-monitoring).\n- An Azure SQL Server and [Azure SQL Database](https://docs.microsoft.com/en-us/azure/azure-sql/database/sql-database-paas-overview) hosting the ProductDB relational database used by the Web App.\n- An Azure Key Vault used to store the following application settings. These settings are automtically created by the ARM template as secrets in Azure Key Vault:\n\n  - Azure Cache for Redis connection string\n  - Azure SQL Database connection string\n  - Application Insights Instrumentation Key\n\n- A private endpoint to the:\n\n  - Azure Blob storage account (boot diagnostics logs)\n  - Azure SQL Database\n  - Azure Key Vault\n\n- A Private DNS Zone Group to link each private endpoint with the corresponding Private DNS Zone.\n- The NIC used by the jumpbox virtual machine and for each private endpoint.\n- A Log Analytics workspace used to monitor the health status of the services such as the hosting plan or NSG.\n- A Private DNS Zone for Azure Blob Storage Account private endpoint (privatelink.blob.core.windows.net)\n- A Private DNS Zone for Azure SQL Database private endpoint (privatelink.database.windows.net)\n- A Private DNS Zone for Azure Key Vault private endpoint (privatelink.vaultcore.azure.net)\n\n## Important Notes\n\nThe two ARM templates disable the public access to both Azure SQL Database and Azure Cache for Redis via the `publicNetworkAccess` parameter which default value is set to `false`. Using private endpoints is not enough to secure an application, you also have to disable the public access to the managed services used by the application, in this case Azure SQL Database and Azure Cache for Redis.\n\nIn addition, both ARM templates automatically create the connection string to both the Azure Cache for Redis and Azure SQL Database as application settings of the Azure App Service. However, in a production environment, it's recommended to access adopt one of the following approaches:\n\n- Use a system assigned managed identity from the Web App to access Azure SQL Database. For more information, see [](https://docs.microsoft.com/en-us/azure/app-service/app-service-web-tutorial-connect-msi). For more information about the Azure Services that support managed identities, see [Services that support managed identities for Azure resources](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/services-support-managed-identities).\n- Store sensitive data like connection strings, encryption keys, certificates, and connection string in [Azure Key Vault](https://docs.microsoft.com/en-us/azure/key-vault/general/overview). For more information, see [Tutorial: Use a managed identity to connect Key Vault to an Azure web app in .NET](https://docs.microsoft.com/en-us/azure/key-vault/general/tutorial-net-create-vault-azure-web-app).\n\n## Prerequisites\n\nThe following components are required to run this sample:\n\n- [.NET Core 3.1](https://dotnet.microsoft.com/download/dotnet-core/3.1)\n- [Visual Studio Code](https://code.visualstudio.com/)\n- [Azure CLI](https://docs.microsoft.com/cli/azure/install-azure-cli?view=azure-cli-latest)\n- [Azure subscription](https://azure.microsoft.com/free/)\n\n## Topology Deployment\n\nYou can use the ARM template and Bash script included in the sample to deploy to Azure the entire infrastructure necessary to host the demo:\n\n```sh\n#!/bin/bash\n\n# Clear the screen\nclear\n\n# Print the menu\necho \"=================================================\"\necho \"Install Demo. Choose an option (1-3): \"\necho \"=================================================\"\noptions=(\"Inject Premium Azure Cache for Redis in a VNET\"\n         \"Azure Cache for Redis with Azure Private Link\"\n         \"Quit\")\n\n# Select an option\nCOLUMNS=0\nselect opt in \"${options[@]}\"; do\n    case $opt in\n    \"Inject Premium Azure Cache for Redis in a VNET\")\n        template=\"../templates/azuredeploy.vnet.json\"\n        parameters=\"../templates/azuredeploy.vnet.parameters.json\"\n        resourceGroupName=\"WebAppSqlDbRedisInVnetRG\"\n        break\n        ;;\n    \"Azure Cache for Redis with Azure Private Link\")\n        template=\"../templates/azuredeploy.endpoint.json\"\n        parameters=\"../templates/azuredeploy.endpoint.parameters.json\"\n        resourceGroupName=\"WebAppSqlDbRedisCacheRG\"\n        break\n        ;;\n    \"Quit\")\n        exit\n        ;;\n    *) echo \"invalid option $REPLY\" ;;\n    esac\ndone\n\n# Variables\nlocation=\"WestEurope\"\n\n# SubscriptionId of the current subscription\nsubscriptionId=$(az account show --query id --output tsv)\nsubscriptionName=$(az account show --query name --output tsv)\n\n# Check if the resource group already exists\ncreateResourceGroup() {\n    local resourceGroupName=$1\n    local location=$2\n\n    # Parameters validation\n    if [[ -z $resourceGroupName ]]; then\n        echo \"The resource group name parameter cannot be null\"\n        exit\n    fi\n\n    if [[ -z $location ]]; then\n        echo \"The location parameter cannot be null\"\n        exit\n    fi\n\n    echo \"Checking if [$resourceGroupName] resource group actually exists in the [$subscriptionName] subscription...\"\n\n    if ! az group show --name \"$resourceGroupName\" \u0026\u003e/dev/null; then\n        echo \"No [$resourceGroupName] resource group actually exists in the [$subscriptionName] subscription\"\n        echo \"Creating [$resourceGroupName] resource group in the [$subscriptionName] subscription...\"\n\n        # Create the resource group\n        if az group create --name \"$resourceGroupName\" --location \"$location\" 1\u003e/dev/null; then\n            echo \"[$resourceGroupName] resource group successfully created in the [$subscriptionName] subscription\"\n        else\n            echo \"Failed to create [$resourceGroupName] resource group in the [$subscriptionName] subscription\"\n            exit\n        fi\n    else\n        echo \"[$resourceGroupName] resource group already exists in the [$subscriptionName] subscription\"\n    fi\n}\n\n# Validate the ARM template\nvalidateTemplate() {\n    local resourceGroupName=$1\n    local template=$2\n    local parameters=$3\n    local arguments=$4\n\n    # Parameters validation\n    if [[ -z $resourceGroupName ]]; then\n        echo \"The resource group name parameter cannot be null\"\n    fi\n\n    if [[ -z $template ]]; then\n        echo \"The template parameter cannot be null\"\n    fi\n\n    if [[ -z $parameters ]]; then\n        echo \"The parameters parameter cannot be null\"\n    fi\n\n    echo \"Validating [$template] ARM template...\"\n\n    if [[ -z $arguments ]]; then\n        error=$(az deployment group validate \\\n            --resource-group \"$resourceGroupName\" \\\n            --template-file \"$template\" \\\n            --parameters \"$parameters\"  2\u003e\u00261 | grep 'ERROR:')\n    else\n        error=$(az deployment group validate \\\n            --resource-group \"$resourceGroupName\" \\\n            --template-file \"$template\" \\\n            --parameters \"$parameters\" \\\n            --arguments $arguments   2\u003e\u00261 | grep 'ERROR:')\n    fi\n\n    if [[ -z $error ]]; then\n        echo \"[$template] ARM template successfully validated\"\n    else\n        echo \"Failed to validate the [$template] ARM template\"\n        echo \"$error\"\n        exit 1\n    fi\n}\n\n# Deploy ARM template\ndeployTemplate() {\n    local resourceGroupName=$1\n    local template=$2\n    local parameters=$3\n    local arguments=$4\n\n    # Parameters validation\n    if [[ -z $resourceGroupName ]]; then\n        echo \"The resource group name parameter cannot be null\"\n        exit\n    fi\n\n    if [[ -z $template ]]; then\n        echo \"The template parameter cannot be null\"\n        exit\n    fi\n\n    if [[ -z $parameters ]]; then\n        echo \"The parameters parameter cannot be null\"\n        exit\n    fi\n\n    # Deploy the ARM template\n    echo \"Deploying [$template] ARM template...\"\n\n    if [[ -z $arguments ]]; then\n         az deployment group create \\\n            --resource-group $resourceGroupName \\\n            --template-file $template \\\n            --parameters $parameters 1\u003e/dev/null\n    else\n         az deployment group create \\\n            --resource-group $resourceGroupName \\\n            --template-file $template \\\n            --parameters $parameters \\\n            --parameters $arguments 1\u003e/dev/null\n    fi\n\n    if [[ $? == 0 ]]; then\n        echo \"[$template] ARM template successfully provisioned\"\n    else\n        echo \"Failed to provision the [$template$] ARM template\"\n        exit -1\n    fi\n}\n\n# Create Resource Group\ncreateResourceGroup \\\n    \"$resourceGroupName\" \\\n     \"$location\"\n\n# Validate ARM Template\nvalidateTemplate \\\n    \"$resourceGroupName\" \\\n    \"$template\" \\\n    \"$parameters\"\n\n# Deploy ARM Template\ndeployTemplate \\\n    \"$resourceGroupName\" \\\n    \"$template\" \\\n    \"$parameters\"\n```\n\n## Create tables and stored procedures\n\nYou can use the following `ProductsDB` T-SQL script to initialize the SQL database used by the ASP.NET Core application.\n\n```SQL\nIF OBJECT_ID('Products') \u003e 0 DROP TABLE [Products]\nGO\n-- Create Products table\nCREATE TABLE [Products]\n(\n    [ProductID] [int] IDENTITY(1,1) NOT NULL ,\n    [Name] [nvarchar](50) NOT NULL ,\n    [Category] [nvarchar](50) NOT NULL ,\n    [Price] [smallmoney] NOT NULL\n        CONSTRAINT [PK_Products] PRIMARY KEY CLUSTERED \n    (\n        [ProductID]\n    )\n)\nGO\n-- Create stored procedures\nIF OBJECT_ID('GetProduct') \u003e 0 DROP PROCEDURE [GetProduct]\nGO\nCREATE PROCEDURE GetProduct\n    @ProductID int\nAS\nSELECT [ProductID], [Name], [Category], [Price]\nFROM [Products]\nWHERE [ProductID] = @ProductID\nGO\nIF OBJECT_ID('GetProducts') \u003e 0 DROP PROCEDURE [GetProducts]\nGO\nCREATE PROCEDURE GetProducts\nAS\nSELECT [ProductID], [Name], [Category], [Price]\nFROM [Products] \nGO\nIF OBJECT_ID('GetProductsByCategory') \u003e 0 DROP PROCEDURE [GetProductsByCategory]\nGO\nCREATE PROCEDURE GetProductsByCategory\n    @Category [nvarchar](50)\nAS\nSELECT [ProductID], [Name], [Category], [Price]\nFROM [Products]\nWHERE [Category] = @Category\nGO\nIF OBJECT_ID('AddProduct') \u003e 0 DROP PROCEDURE [AddProduct]\nGO\nCREATE PROCEDURE AddProduct\n    @ProductID int OUTPUT,\n    @Name [nvarchar](50),\n    @Category [nvarchar](50),\n    @Price [smallmoney]\nAS\nINSERT INTO Products\nVALUES\n    (@Name, @Category, @Price)\nSET @ProductID = @@IDENTITY\nGO\nIF OBJECT_ID('UpdateProduct') \u003e 0 DROP PROCEDURE [UpdateProduct]\nGO\nCREATE PROCEDURE UpdateProduct\n    @ProductID int,\n    @Name [nvarchar](50),\n    @Category [nvarchar](50),\n    @Price [smallmoney]\nAS\nUPDATE Products \nSET [Name] = @Name,\n    [Category] = @Category,\n    [Price] = @Price\nWHERE [ProductID] = @ProductID\nGO\nIF OBJECT_ID('DeleteProduct') \u003e 0 DROP PROCEDURE [DeleteProduct]\nGO\nCREATE PROCEDURE DeleteProduct\n    @ProductID int\nAS\nDELETE [Products]\nWHERE [ProductID] = @ProductID\nGO\n-- Create test data\nSET NOCOUNT ON\nGO\nINSERT INTO Products\nVALUES\n    (N'Tomato soup', N'Groceries', 1.39)\nGO\nINSERT INTO Products\nVALUES\n    (N'Babo', N'Toys', 19.99)\nGO\nINSERT INTO Products\nVALUES\n    (N'Hammer', N'Hardware', 16.49)\nGO\n```\n\nYou can proceed as follows to create the tables and stored procedure in the SQL database:\n\n- VPN into the jumpbox virtual machine using Azure Bastion as shown in the picture below\n- Open a browser and connect to the Azure Portal\n- Open the Query Editor under the Azure SQL Database resource\n- Copy and paste the code in `ProductsDB` T-SQL script into a new query\n- Execute the scripts that creates the tables and some test data in the Products table used by the Web App\n\n![Resources](images/bastion.png)\n\n## ASP.NET Core application\n\nThis sample provides an ASP.NET Core single-page application (SPA) to test the topology. The application reads:\n\n- Azure Cache for Redis connection string\n- Azure SQL Database connection string\n- Application Insights Instrumentation Key\n\napplication settings from Azure Key Vault using the following code defined in the `Program` class. For more information, see [Azure Key Vault configuration provider in ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/security/key-vault-configuration?view=aspnetcore-5.0). The application uses the system-assigned managed identity of the App Service to access secrets from Azure Key Vault. The ARM template creates Key Vault, the secrets used application settings by the ASP.NET Core aaplication, and the access policies to grant permissions on secrets to the system-assigned managed identity. For more information, see [How to use managed identities for App Service and Azure Functions](https://docs.microsoft.com/en-us/azure/app-service/overview-managed-identity?tabs=dotnet).\n\n### Program.cs\n\n```csharp\nusing System;\nusing Microsoft.AspNetCore.Hosting;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.Hosting;\nusing Azure.Identity;\nusing Azure.Security.KeyVault.Secrets;\nusing Azure.Extensions.AspNetCore.Configuration.Secrets;\nusing Products.Properties;\n\nnamespace Products\n{\n    public class Program\n    {\n        public static void Main(string[] args)\n        {\n            CreateHostBuilder(args).Build().Run();\n        }\n\n        public static IHostBuilder CreateHostBuilder(string[] args) =\u003e\n            Host.CreateDefaultBuilder(args)\n                .ConfigureAppConfiguration((context, config) =\u003e\n                {\n                    var builtConfig = config.Build();\n                    var keyVaultUri = builtConfig[Resources.KeyVaultUri];\n                    if (string.IsNullOrEmpty(keyVaultUri))\n                    {\n                        throw new Exception(\"KeyVaultUri parameter in the appsettings.json cannot be null or empty\");\n                    }\n                    var secretClient = new SecretClient(\n                        new Uri(keyVaultUri),\n                        new DefaultAzureCredential());\n                    config.AddAzureKeyVault(secretClient, new KeyVaultSecretManager());\n                })\n                .ConfigureWebHostDefaults(webBuilder =\u003e webBuilder.UseStartup\u003cStartup\u003e());\n    }\n}\n```\n\nThe application makes use of the following libraries and features:\n\n- [StackExchange.Redis](https://github.com/StackExchange/StackExchange.Redis) library to read, create, update, and delete string values and [sets](https://redis.io/topics/data-types) in the Azure Cache for Redis. For more information, see [Quickstart: Use Azure Cache for Redis with an ASP.NET web app](https://docs.microsoft.com/en-us/azure/azure-cache-for-redis/cache-web-app-howto). \n- [Entity Framework 5.0](https://docs.microsoft.com/en-us/ef/core/what-is-new/ef-core-5.0/whatsnew) to read, create, update, and delete records from the Products table in the Azure SQL Database. For more information, see [Tutorial: Get started with EF Core in an ASP.NET MVC web app](https://docs.microsoft.com/en-us/aspnet/core/data/ef-mvc/intro?view=aspnetcore-5.0).\n- [Dependency injection in ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection) to inject services into the constructor of the class where it's used. The framework takes on the responsibility of creating an instance of the dependency and disposing of it when it's no longer needed. For more information, see the code of the `ConfigureServices` method in the `Startup` class below.\n\n### Startup.cs\n\n```csharp\nusing System;\nusing Microsoft.OpenApi.Models;\nusing Microsoft.AspNetCore.Builder;\nusing Microsoft.AspNetCore.Hosting;\nusing Microsoft.Extensions.Configuration;\nusing Microsoft.Extensions.DependencyInjection;\nusing Microsoft.Extensions.Hosting;\nusing Microsoft.EntityFrameworkCore;\nusing StackExchange.Redis;\nusing Products.Properties;\nusing Products.Models;\nusing Products.Helpers;\n\nnamespace Products\n{\n    public class Startup\n    {\n        /// \u003csummary\u003e\n        /// Creates an instance of the Startup class\n        /// \u003c/summary\u003e\n        /// \u003cparam name=\"configuration\"\u003eThe configuration created by the CreateDefaultBuilder.\u003c/param\u003e\n        public Startup(IConfiguration configuration)\n        {\n            Configuration = configuration;\n        }\n\n        /// \u003csummary\u003e\n        /// Gets or sets the Configuration property.\n        /// \u003c/summary\u003e\n        public IConfiguration Configuration { get; }\n\n        /// \u003csummary\u003e\n        /// This method gets called by the runtime. Use this method to add services to the container.\n        /// \u003c/summary\u003e\n        /// \u003cparam name=\"services\"\u003eThe services collection.\u003c/param\u003e\n        public void ConfigureServices(IServiceCollection services)\n        {\n            services.AddControllersWithViews();\n            services.AddApplicationInsightsTelemetry(Configuration[Resources.ApplicationInsightsConnectionString]);\n            services.AddOptions();\n            services.AddMvc();\n            services.AddSingleton\u003cIConnectionMultiplexer\u003e(ConnectionMultiplexer.Connect(Configuration.GetConnectionString(Resources.RedisCacheConnectionString)));\n            services.AddDbContext\u003cProductsContext\u003e(options =\u003e options.UseSqlServer(Configuration.GetConnectionString(Resources.SqlServerConnectionString)));\n\n            // Register the Swagger generator, defining one or more Swagger documents\n            services.AddSwaggerGen(c =\u003e\n            {\n                c.SwaggerDoc(\"v1\", new OpenApiInfo\n                {\n                    Version = \"v1\",\n                    Title = \"Products API\",\n                    Description = \"A simple example ASP.NET Core Web API\",\n                    TermsOfService = new Uri(\"https://www.apache.org/licenses/LICENSE-2.0\"),\n                    Contact = new OpenApiContact\n                    {\n                        Name = \"Paolo Salvatori\",\n                        Email = \"paolos@microsoft.com\",\n                        Url = new Uri(\"https://github.com/paolosalvatori\")\n                    },\n                    License = new OpenApiLicense\n                    {\n                        Name = \"Use under Apache License 2.0\",\n                        Url = new Uri(\"https://www.apache.org/licenses/LICENSE-2.0\")\n                    }\n                });\n            });\n        }\n\n        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.\n        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)\n        {\n            if (env.IsDevelopment())\n            {\n                app.UseDeveloperExceptionPage();\n            }\n            else\n            {\n                app.UseExceptionHandler(\"/Home/Error\");\n                // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.\n                app.UseHsts();\n            }\n\n            // Enable middleware to serve generated Swagger as a JSON endpoint.\n            app.UseSwagger();\n\n            // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.), specifying the Swagger JSON endpoint.\n            app.UseSwaggerUI(c =\u003e\n            {\n                c.SwaggerEndpoint(\"/swagger/v1/swagger.json\", \"TodoList API V1\");\n                c.RoutePrefix = \"swagger\";\n            });\n\n            app.UseHttpsRedirection();\n            app.UseStaticFiles();\n\n            app.UseRouting();\n\n            app.UseAuthorization();\n\n            app.UseEndpoints(endpoints =\u003e\n            {\n                endpoints.MapControllerRoute(\n                    name: \"default\",\n                    pattern: \"{controller=Home}/{action=Index}/{id?}\");\n            });\n        }\n    }\n}\n```\n\nThe table below shows the code of the REST API implemented by the `ProductsController` class. This API is called via [jQuery](https://api.jquery.com/jquery.ajax/) by the client-side script running in the single-page application.\n\n```csharp\nusing System;\nusing System.Linq;\nusing System.Data;\nusing System.Diagnostics;\nusing System.Threading.Tasks;\nusing System.Globalization;\nusing Microsoft.AspNetCore.Mvc;\nusing Microsoft.Data.SqlClient;\nusing Microsoft.Extensions.Logging;\nusing Microsoft.EntityFrameworkCore;\nusing StackExchange.Redis;\nusing Products.Models;\nusing Products.Properties;\nusing Products.Helpers;\n\nnamespace Products.Controllers\n{\n    [Route(\"api/[controller]\")]\n    [Produces(\"application/json\")]\n    [ApiController]\n    public class ProductsController : ControllerBase\n    {\n        #region Private Instance Fields\n        private readonly ILogger\u003cProductsController\u003e logger;\n        private readonly ProductsContext context;\n        private readonly IDatabase database;\n        #endregion\n\n        #region Public Constructors\n        public ProductsController(ILogger\u003cProductsController\u003e logger,\n                                  ProductsContext context,\n                                  IConnectionMultiplexer connectionMultiplexer)\n        {\n            this.logger = logger;\n            this.context = context;\n            database = connectionMultiplexer.GetDatabase();\n        }\n        #endregion\n\n        #region Public Methods\n        /// \u003csummary\u003e\n        /// Gets all the products.\n        /// \u003c/summary\u003e\n        /// \u003creturns\u003eAll the products.\u003c/returns\u003e\n        /// \u003cresponse code=\"200\"\u003eGet all the products, if any.\u003c/response\u003e\n        [HttpGet]\n        [ProducesResponseType(typeof(Product), 200)]\n        public async Task\u003cIActionResult\u003e GetAllProductsAsync()\n        {\n            var stopwatch = new Stopwatch();\n\n            try\n            {\n                stopwatch.Start();\n                logger.LogInformation(\"Listing all products...\");\n                var values = await database.SetMembersAsync(Resources.RedisKeys);\n                var items = await database.GetAsync\u003cProduct\u003e(values.Select(v =\u003e (string)v).ToArray());\n                if (items.Any())\n                {\n                    var list = items.ToList();\n                    list.Sort((x, y) =\u003e x.ProductId - y.ProductId);\n                    return new OkObjectResult(list.ToArray());\n                }\n                var products = context.Products.FromSqlRaw(Resources.GetProducts);\n                foreach (var product in products)\n                {\n                    var idAsString = product.ProductId.ToString(CultureInfo.InvariantCulture);\n                    await database.SetAsync(idAsString, product);\n                    await database.SetAddAsync(Resources.RedisKeys, idAsString);\n                }\n                return new OkObjectResult(products.ToArray());\n            }\n            catch (Exception ex)\n            {\n                var errorMessage = MessageHelper.FormatException(ex);\n                logger.LogError(errorMessage);\n                return StatusCode(400, new { error = errorMessage });\n            }\n            finally\n            {\n                stopwatch.Stop();\n                logger.LogInformation($\"GetAllProductsAsync method completed in {stopwatch.ElapsedMilliseconds} ms.\");\n            }\n        }\n\n        /// \u003csummary\u003e\n        /// Gets a specific product by id.\n        /// \u003c/summary\u003e\n        /// \u003cparam name=\"id\"\u003eId of the product.\u003c/param\u003e\n        /// \u003creturns\u003eProduct with the specified id.\u003c/returns\u003e\n        /// \u003cresponse code=\"200\"\u003eProduct found\u003c/response\u003e\n        /// \u003cresponse code=\"404\"\u003eProduct not found\u003c/response\u003e     \n        [HttpGet(\"{id}\", Name = \"GetProductByIdAsync\")]\n        [ProducesResponseType(typeof(Product), 200)]\n        [ProducesResponseType(typeof(Product), 404)]\n        public async Task\u003cIActionResult\u003e GetProductByIdAsync(int id)\n        {\n            var stopwatch = new Stopwatch();\n\n            try\n            {\n                stopwatch.Start();\n                logger.LogInformation($\"Getting product {id}...\");\n                var product = await database.GetAsync\u003cProduct\u003e(id.ToString());\n                if (product != null)\n                {\n                    return new OkObjectResult(product);\n                }\n\n                var products = context.Products.FromSqlRaw(Resources.GetProduct, new SqlParameter\n                {\n                    ParameterName = \"@ProductID\",\n                    Direction = ParameterDirection.Input,\n                    SqlDbType = SqlDbType.Int,\n                    Value = id\n                });\n                if (products.Any())\n                {\n                    product = products.FirstOrDefault();\n                    var idAsString = product.ProductId.ToString(CultureInfo.InvariantCulture);\n                    await database.SetAsync(idAsString, product);\n                    await database.SetAddAsync(Resources.RedisKeys, idAsString);\n\n                    logger.LogInformation($\"Product with id = {product.ProductId} has been successfully retrieved.\");\n                    return new OkObjectResult(product);\n                }\n                else\n                {\n                    logger.LogWarning($\"No product with id = {id} was found\");\n                    return null;\n                }\n            }\n            catch (Exception ex)\n            {\n                var errorMessage = MessageHelper.FormatException(ex);\n                logger.LogError(errorMessage);\n                return StatusCode(400, new { error = errorMessage });\n            }\n            finally\n            {\n                stopwatch.Stop();\n                logger.LogInformation($\"GetProductByIdAsync method completed in {stopwatch.ElapsedMilliseconds} ms.\");\n            }\n        }\n\n        /// \u003csummary\u003e\n        /// Creates a new product.\n        /// \u003c/summary\u003e\n        /// \u003cremarks\u003e\n        /// \u003c/remarks\u003e\n        /// \u003cparam name=\"product\"\u003eProduct to create.\u003c/param\u003e\n        /// \u003creturns\u003eIf the operation succeeds, it returns the newly created product.\u003c/returns\u003e\n        /// \u003cresponse code=\"201\"\u003eProduct successfully created.\u003c/response\u003e\n        /// \u003cresponse code=\"400\"\u003eProduct is null.\u003c/response\u003e     \n        [HttpPost]\n        [ProducesResponseType(typeof(Product), 201)]\n        [ProducesResponseType(typeof(Product), 400)]\n        public async Task\u003cIActionResult\u003e CreateProductAsync(Product product)\n        {\n            var stopwatch = new Stopwatch();\n\n            try\n            {\n                stopwatch.Start();\n                if (product == null)\n                {\n                    logger.LogWarning(\"Product cannot be null.\");\n                    return BadRequest();\n                }\n\n                var productIdParameter = new SqlParameter\n                {\n                    ParameterName = \"@ProductID\",\n                    Direction = ParameterDirection.Output,\n                    SqlDbType = SqlDbType.Int\n                };\n\n                var result = await context.Database.ExecuteSqlRawAsync(Resources.AddProduct, new SqlParameter[] {\n                    productIdParameter,\n                    new SqlParameter\n                    {\n                        ParameterName = \"@Name\",\n                        Direction = ParameterDirection.Input,\n                        SqlDbType = SqlDbType.NVarChar,\n                        Size = 50,\n                        Value = product.Name\n                    },\n                    new SqlParameter\n                    {\n                        ParameterName = \"@Category\",\n                        Direction = ParameterDirection.Input,\n                        SqlDbType = SqlDbType.NVarChar,\n                        Size = 50,\n                        Value = product.Category\n                    },\n                    new SqlParameter\n                    {\n                        ParameterName = \"@Price\",\n                        Direction = ParameterDirection.Input,\n                        SqlDbType = SqlDbType.SmallMoney,\n                        Value = product.Price\n                    }\n                });\n                if (result ==1 \u0026\u0026 productIdParameter.Value != null)\n                {\n                    product.ProductId = (int)productIdParameter.Value;\n                    var idAsString = product.ProductId.ToString(CultureInfo.InvariantCulture);\n                    await database.SetAsync(idAsString, product);\n                    await database.SetAddAsync(Resources.RedisKeys, idAsString);\n                    \n                    logger.LogInformation($\"Product with id = {product.ProductId} has been successfully created.\");\n                    return CreatedAtRoute(\"GetProductByIdAsync\", new { id = product.ProductId }, product);\n                }\n                return null;\n            }\n            catch (Exception ex)\n            {\n                var errorMessage = MessageHelper.FormatException(ex);\n                logger.LogError(errorMessage);\n                return StatusCode(400, new { error = errorMessage });\n            }\n            finally\n            {\n                stopwatch.Stop();\n                logger.LogInformation($\"CreateProductAsync method completed in {stopwatch.ElapsedMilliseconds} ms.\");\n            }\n        }\n\n        /// \u003csummary\u003e\n        /// Updates a product. \n        /// \u003c/summary\u003e\n        /// \u003cparam name=\"id\"\u003eThe id of the product.\u003c/param\u003e\n        /// \u003cparam name=\"product\"\u003eProduct to update.\u003c/param\u003e\n        /// \u003creturns\u003eNo content.\u003c/returns\u003e\n        /// \u003cresponse code=\"204\"\u003eNo content if the product is successfully updated.\u003c/response\u003e\n        /// \u003cresponse code=\"404\"\u003eIf the product is not found.\u003c/response\u003e\n        [HttpPut(\"{id}\")]\n        [ProducesResponseType(typeof(Product), 204)]\n        [ProducesResponseType(typeof(Product), 404)]\n        public async Task\u003cIActionResult\u003e Update(int id, [FromBody] Product product)\n        {\n            var stopwatch = new Stopwatch();\n\n            try\n            {\n                stopwatch.Start();\n                if (product == null || product.ProductId != id)\n                {\n                    logger.LogWarning(\"The product is null or its id is different from the id in the payload.\");\n                    return BadRequest();\n                }\n\n                var result = await context.Database.ExecuteSqlRawAsync(Resources.UpdateProduct, new SqlParameter[] {\n                    new SqlParameter\n                    {\n                        ParameterName = \"@ProductID\",\n                        Direction = ParameterDirection.Input,\n                        SqlDbType = SqlDbType.Int,\n                        Value = product.ProductId\n                    },\n                    new SqlParameter\n                    {\n                        ParameterName = \"@Name\",\n                        Direction = ParameterDirection.Input,\n                        SqlDbType = SqlDbType.NVarChar,\n                        Size = 50,\n                        Value = product.Name\n                    },\n                    new SqlParameter\n                    {\n                        ParameterName = \"@Category\",\n                        Direction = ParameterDirection.Input,\n                        SqlDbType = SqlDbType.NVarChar,\n                        Size = 50,\n                        Value = product.Category\n                    },\n                    new SqlParameter\n                    {\n                        ParameterName = \"@Price\",\n                        Direction = ParameterDirection.Input,\n                        SqlDbType = SqlDbType.SmallMoney,\n                        Value = product.Price\n                    }\n                });\n\n                if (result == 1)\n                {\n                    var idAsString = id.ToString(CultureInfo.InvariantCulture);\n                    await database.SetAsync(idAsString, product);\n                    await database.SetAddAsync(Resources.RedisKeys, idAsString);\n\n                    logger.LogInformation(\"Product with id = {ID} has been successfully updated.\", product.ProductId);\n                }\n                return new NoContentResult();\n            }\n            catch (Exception ex)\n            {\n                var errorMessage = MessageHelper.FormatException(ex);\n                logger.LogError(errorMessage);\n                return StatusCode(400, new { error = errorMessage });\n            }\n            finally\n            {\n                stopwatch.Stop();\n                logger.LogInformation($\"Update method completed in {stopwatch.ElapsedMilliseconds} ms.\");\n            }\n        }\n\n        /// \u003csummary\u003e\n        /// Deletes a specific product.\n        /// \u003c/summary\u003e\n        /// \u003cparam name=\"id\"\u003eThe id of the product.\u003c/param\u003e      \n        /// \u003creturns\u003eNo content.\u003c/returns\u003e\n        /// \u003cresponse code=\"202\"\u003eNo content if the product is successfully deleted.\u003c/response\u003e\n        /// \u003cresponse code=\"404\"\u003eIf the product is not found.\u003c/response\u003e\n        [HttpDelete(\"{id}\")]\n        [ProducesResponseType(typeof(Product), 204)]\n        [ProducesResponseType(typeof(Product), 404)]\n        public async Task\u003cIActionResult\u003e Delete(string id)\n        {\n            var stopwatch = new Stopwatch();\n\n            try\n            {\n                stopwatch.Start();\n\n                var result = await context.Database.ExecuteSqlRawAsync(Resources.DeleteProduct, new SqlParameter[] {\n                    new SqlParameter\n                    {\n                        ParameterName = \"@ProductID\",\n                        Direction = ParameterDirection.Input,\n                        SqlDbType = SqlDbType.Int,\n                        Value = id\n                    }\n                });\n\n                if (result == 1)\n                {\n                    var idAsString = id.ToString(CultureInfo.InvariantCulture);\n                    await database.KeyDeleteAsync(idAsString);\n                    await database.SetRemoveAsync(Resources.RedisKeys, idAsString);\n\n                    logger.LogInformation(\"Product with id = {ID} has been successfully deleted.\", id);\n                }\n                return new NoContentResult();\n            }\n            catch (Exception ex)\n            {\n                var errorMessage = MessageHelper.FormatException(ex);\n                logger.LogError(errorMessage);\n                return StatusCode(400, new { error = errorMessage });\n            }\n            finally\n            {\n                stopwatch.Stop();\n                logger.LogInformation($\"Delete method completed in {stopwatch.ElapsedMilliseconds} ms.\");\n            }\n        }\n        #endregion\n    }\n}\n```\n\n## Deploy the code of the ASP.NET Core application\n\nOnce the Azure resources have been deployed to Azure (which can take about 10-12 minutes), you need to deploy the ASP.NET Core web application contained in the `src` folder to the newly created Azure App Service. You can customize and use the `deploy-web-app-to-azure.yml` GitHub Actions workflow under the `.github\\workflow` folder to deploy the application to Azure App Service. As an alternative, you can use [Visual Studio Code](https://code.visualstudio.com/) or [Visual Studio](https://visualstudio.microsoft.com/) to deploy the ASP.NET Core application to the Azure App Service created by the ARM template.\n\n## Test the Application\n\nAfter creating the database and deploying the Web App, you can simply navigate to the URL of your Azure App Service to check if the application is up and running, as shown in the following figure.\n\n![Resources](images/demo.png)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpaolosalvatori%2Fweb-app-redis-sql-db","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpaolosalvatori%2Fweb-app-redis-sql-db","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpaolosalvatori%2Fweb-app-redis-sql-db/lists"}