How to Deploy Custom Error Pages in Azure App Services

Have you ever tried browsing a site, and all you see is a blank page with a massive H1 “502 Bad Gateway”, with an nginx footer? Not a good look, in my opinion.

Example of what we shouldn’t see

Custom error pages are a highly useful feature because it gives your organisation the ability to provide a better user experience during service disruptions. Rather than returning the application’s generic error message that may confuse the end users, you can return a company branded, user-friendly page which could explain the issue (“we’re undergoing some maintenance”) as well as outline next steps (i.e. trying again later). Keep reading to find out how this can be done in Azure.

Custom Error Pages in Public Preview

Azure App services have had the capability to deploy custom error pages (for status 403, 502, and 503 errors) in public preview since May 2023 – Azure Updates link. As of November 2024 however, no support is yet in place for deploying these error pages programmatically through the AZ CLI or any IAC templates. These error pages need to be deployed manually through the Azure portal, which is time consuming when dealing with many app services at once.

Azure portal showing an app service's custom error page configuration
Custom error page configuration in the Azure Portal

Fortunately, it isn’t too difficult to reverse engineer what is going on when deploying through the portal. Looking into the Network tab of your browser, you’ll see that deploying the error page is done by making a REST call to the Azure API with a JSON payload like so:

{
  "requests": [
    {
      "httpMethod": "PUT",
      "content": {
        "properties": {
          "content": "{ BASE64 ENCODED HTML FILE }"
        }
      },
      "requestHeaderDetails": {
        "commandName": "AddOrUpdateCustomErrorPageForSite"
      },
      "url": "{ APP SVC RESOUCE ID }/errorpages/503?api-version=2022-03-01"
    }
  ]
}

Deploying at Scale

With knowledge of the API call payload in hand, you can now craft up a script to then deploy error pages en masse! This can be done with any CLI (i.e. bash / PowerShell) that you have the AZ CLI installed on, but for the purpose of demonstration, I will be doing it in PowerShell below.

Let’s start off with a folder structure like so:

error-page-directory
├── 403.html
├── 502.html
├── 503.html
└── deploy-custom-error-page.ps1

In your PowerShell script, insert the following:

# deploy-custom-error-page.ps1

$environments = @{
 dev  = @(
   "/subscriptions/<ID>/resourceGroups/webrg/providers/Microsoft.Web/sites/web",
   "/subscriptions/<ID>/resourceGroups/webrg/providers/Microsoft.Web/sites/api"
 )
 test = @(
   "/subscriptions/<ID>/resourceGroups/webrg/providers/Microsoft.Web/sites/web",
   "/subscriptions/<ID>/resourceGroups/webrg/providers/Microsoft.Web/sites/api"
 )}

# Convert HTML files to base64
$content403 = Get-Content ./403.html -Raw
$html403 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($content403))

$content502 = Get-Content ./502.html -Raw
$html502 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($content502))

$content503 = Get-Content ./503.html -Raw
$html503 = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($content503))

Here, we create our variables:

  • $environments
    • This creates an object with each environment name as a “key”, and the value as an array of app service resource IDs that you want to deploy the error pages to.
  • $content403…
    • Converts the HTML page for each error code into base64 strings as that is what the Azure API is expecting in the payload.

Next, we create a function that will be used to loop through an array of $appServiceIds and deploy the error pages to the app services.

# deploy-custom-error-page.ps1 continued

function Update-ErrorPages {
  param (
    [Parameter(Mandatory = $true)]
    [array]$appServiceIds
  )

  # Initialize array for batch api requests per environment
  $requests = @()

  # Generate payload for each app service
  foreach ($appId in $appServiceIds) {
    $payloads = @(
      @{
        httpMethod           = 'PUT'
        content              = @{ properties = @{ content = $html403 } }
        requestHeaderDetails = @{ commandName = 'AddOrUpdateCustomErrorPageForSite' }
        url                  = "$appId/errorpages/403?api-version=2022-03-01"
      },
      @{
        httpMethod           = 'PUT'
        content              = @{ properties = @{ content = $html502 } }
        requestHeaderDetails = @{ commandName = 'AddOrUpdateCustomErrorPageForSite' }
        url                  = "$appId/errorpages/502?api-version=2022-03-01"
      },
      @{
        httpMethod           = 'PUT'
        content              = @{ properties = @{ content = $html503 } }
        requestHeaderDetails = @{ commandName = 'AddOrUpdateCustomErrorPageForSite' }
        url                  = "$appId/errorpages/503?api-version=2022-03-01"
      }
    )

    # Convert payload to JSON format as a single string
    $jsonOutput = ($payloads | ForEach-Object { $_ | ConvertTo-Json -Compress }) -join ", "

    # Append to array of requests
    $requests += $jsonOutput
  }

  # Convert requests array to string
  $requestsString = $requests -join ", "

  # Make AZ rest call
  $response = az rest --method post --url "https://management.azure.com/batch?api-version=2015-11-01" --body "{'requests': [$requestsString]}"

  # Parse the response as JSON
  $responseJson = $response | ConvertFrom-Json

  # Check for any httpStatusCode that is not 200
  $failedResponses = $responseJson.responses | Where-Object { $_.httpStatusCode -ne 200 }

  if ($failedResponses) {
    # Throw an error if any responses failed
    $failedMessage = $failedResponses | ForEach-Object { 
      # Error message may be stored in multiple locations 
      $_.content.error.message ?? $_.content.Message ?? "Unknown error occurred"
    }
    Write-Error "The following error message was returned: `n- $($failedMessage -join "`n- ")`n "
    throw "Not all requests completed successfully. Check outputs for error messages."
  }
  else {
    Write-Host "All requests completed"
  }
}

Azure’s Batch API accepts a maximum of 20 requests per call. Thus, we create a function that deploys the error pages in multiple API calls. If you have 7 or more app services, you will need to further split the requests up (7 app services * 3 custom error pages each = 21 request, exceeding Batch API limits).

In this function, we:

  • Initialise the $requests array.
  • Loop through the $appServiceIds array to gradually build the JSON payload we saw at the start.
  • Convert the $requests array into $requestsString to be interpolated into the request payload.
  • Using the AZ CLI, invoke the “az rest” command to fire the API request.
    • “az rest” takes care of retrieving and appending the Bearer authentication token to the request, which you would otherwise need to do if you used “curl” instead.
    • Batch REST calls will return a 200 status even if some of the contained requests fail. Therefore, we need to create our own error handling logic.
  • $failedResponses filters the response from the “az rest” call and throws an error if any of the responses within the batch returns with a non 200 status.

Finally, we loop through the $environments object created at the very beginning and invoke the Update-ErrorPages function for each environment.

# deploy-custom-error-page.ps1 continued 

# Loop through each environment and process requests sequentially
foreach ($environment in $environments.Keys) {
  Write-Host "Processing $environment..."
  $appServiceIds = $environments[$environment]
  
  # Send batch requests for the environment
  Update-ErrorPages -appServiceIds $appServiceIds
  
  Write-Host "$environment processed successfully. `n "
}

If you are doing this on your local machine, simply run the PowerShell script and it will deploy the custom error pages to your app services.

$ .\deploy-custom-error-page.ps1

Browse to your App Service > Configuration > Error pages and you should now see each status code with a status of “Configured”!

Custom error pages configured

Final Note

For this demonstration, the deployment would have been done on a dev’s local machine, using the dev’s user account that has write access to the app services. In alignment with best practices, this should be migrated to a CI/CD pipeline to be deployed by a service account. If you require assistance on this, feel free to reach out to us at Arinco and we would be happy to assist.

Read more recent blogs

Get started on the right path to cloud success today. Our Crew are standing by to answer your questions and get you up and running.