Azure App Service with Autoscaling deploy with Terraform

Hey DevOps SRE engineers!!

Beginning Learning New concepts in Azure Cloud

 Prerequisites: 

  Terraform 1.5+, Azure CLI, and an Azure subscription with permission to create these resources. Sign in and select the intended subscription before applying it.

Modularization in Terraform 

Define a dedicated folder 'modules', place a child dir as 'app_service' main.tf in the modules/app_service folder
resource "random_string" "suffix" {
  length  = 6
  lower   = true
  upper   = false
  numeric = true
  special = false
}

locals {
  resource_suffix = coalesce(var.resource_suffix, random_string.suffix.result)
}

resource "azurerm_resource_group" "app" {
  name     = var.resource_group_name
  location = var.location
}

resource "azurerm_log_analytics_workspace" "app" {
  name                = "law-${var.app_name_prefix}-${local.resource_suffix}"
  location            = azurerm_resource_group.app.location
  resource_group_name = azurerm_resource_group.app.name
  sku                 = "PerGB2018"
  retention_in_days   = 30
}

resource "azurerm_application_insights" "app" {
  name                = "appi-${var.app_name_prefix}-${local.resource_suffix}"
  location            = azurerm_resource_group.app.location
  resource_group_name = azurerm_resource_group.app.name
  application_type    = "web"
  workspace_id        = azurerm_log_analytics_workspace.app.id
  retention_in_days   = 30
}

resource "azurerm_service_plan" "app" {
  name                = "asp-${var.app_name_prefix}-${local.resource_suffix}"
  location            = azurerm_resource_group.app.location
  resource_group_name = azurerm_resource_group.app.name
  os_type             = "Linux"
  sku_name            = var.service_plan_sku
}

data "archive_file" "webapp" {
  type        = "zip"
  source_dir  = var.app_source_dir
  output_path = "${path.root}/.terraform/${var.resource_group_name}-${var.app_name_prefix}-webapp.zip"
}

resource "azurerm_linux_web_app" "app" {
  name                = "${var.web_app_name_prefix}-${local.resource_suffix}"
  location            = azurerm_resource_group.app.location
  resource_group_name = azurerm_resource_group.app.name
  service_plan_id     = azurerm_service_plan.app.id
  https_only          = true
  zip_deploy_file     = data.archive_file.webapp.output_path

  app_settings = {
    APPLICATIONINSIGHTS_CONNECTION_STRING = azurerm_application_insights.app.connection_string
    APP_PACKAGE_HASH                      = data.archive_file.webapp.output_base64sha256
    SCM_DO_BUILD_DURING_DEPLOYMENT        = "true"
  }

  site_config {
    always_on                         = true
    app_command_line                  = "npm start"
    health_check_path                 = "/health"
    health_check_eviction_time_in_min = 10

    application_stack {
      node_version = "20-lts"
    }
  }

  logs {
    application_logs {
      file_system_level = "Information"
    }
  }
}

resource "azurerm_monitor_autoscale_setting" "app" {
  name                = "autoscale-${var.app_name_prefix}-${local.resource_suffix}"
  location            = azurerm_resource_group.app.location
  resource_group_name = azurerm_resource_group.app.name
  target_resource_id  = azurerm_service_plan.app.id

  profile {
    name = "cpu-based-autoscale"

    capacity {
      default = var.default_instance_count
      minimum = var.min_instance_count
      maximum = var.max_instance_count
    }

    rule {
      metric_trigger {
        metric_name        = "CpuPercentage"
        metric_resource_id = azurerm_service_plan.app.id
        time_grain         = "PT1M"
        statistic          = "Average"
        time_window        = "PT10M"
        time_aggregation   = "Average"
        operator           = "GreaterThan"
        threshold          = 70
      }

      scale_action {
        direction = "Increase"
        type      = "ChangeCount"
        value     = "1"
        cooldown  = "PT5M"
      }
    }

    rule {
      metric_trigger {
        metric_name        = "CpuPercentage"
        metric_resource_id = azurerm_service_plan.app.id
        time_grain         = "PT1M"
        statistic          = "Average"
        time_window        = "PT20M"
        time_aggregation   = "Average"
        operator           = "LessThan"
        threshold          = 30
      }

      scale_action {
        direction = "Decrease"
        type      = "ChangeCount"
        value     = "1"
        cooldown  = "PT10M"
      }
    }
  }
}

Variables for App Service 

modules/app_service/variables.tf Create a resource group, App Service Plan, and Web App. Use Linux unless you need Windows.

Add Application Insights and Log Analytics so you can see what scaling is reacting to.

variable "location" {
  description = "Azure region for the resources."
  type        = string
}

variable "resource_group_name" {
  description = "Name of the resource group to create."
  type        = string
}

variable "app_source_dir" {
  description = "Path to the directory containing the web app source."
  type        = string
}

variable "app_name_prefix" {
  description = "Prefix used for the App Service Plan and monitoring resource names."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z0-9-]+$", var.app_name_prefix))
    error_message = "app_name_prefix must contain only letters, numbers, and hyphens."
  }
}

variable "web_app_name_prefix" {
  description = "Prefix used for the globally unique Web App name."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z0-9-]+$", var.web_app_name_prefix))
    error_message = "web_app_name_prefix must contain only letters, numbers, and hyphens."
  }
}

variable "resource_suffix" {
  description = "Optional six-character suffix for resource names; leave null to generate one."
  type        = string
  default     = null
  nullable    = true

  validation {
    condition     = var.resource_suffix == null || can(regex("^[a-z0-9]{6}$", var.resource_suffix))
    error_message = "resource_suffix must be null or exactly six lowercase letters or digits."
  }
}

variable "service_plan_sku" {
  description = "SKU for the Linux App Service Plan."
  type        = string
  default     = "S1"
}

variable "min_instance_count" {
  description = "Minimum number of App Service Plan instances."
  type        = number
  default     = 1
}

variable "default_instance_count" {
  description = "Default number of App Service Plan instances."
  type        = number
  default     = 1
}

variable "max_instance_count" {
  description = "Maximum number of App Service Plan instances."
  type        = number
  default     = 3

  validation {
    condition = (
      var.min_instance_count >= 1 &&
      var.min_instance_count <= var.default_instance_count &&
      var.default_instance_count <= var.max_instance_count
    )
    error_message = "Instance counts must satisfy 1 \<= minimum \<= default \<= maximum."
  }
}

Define the autoscale setting with a minimum, default and maximum instance count, plus scale-out and scale-in rules based on metrics (CPU, memory, or HTTP queue length). 

 modules/app_service/version.tf
terraform {
  required_providers {
    azurerm = {
      source = "hashicorp/azurerm"
    }
    archive = {
      source = "hashicorp/archive"
    }
    random = {
      source = "hashicorp/random"
    }
  }
}

Deploy the application code (zip deploy, GitHub Actions, or a container image).

Web App Service output

Once the terraform apply successful you will be seeing the web application available URL as output.

modules/app_service/output.tf The output we can define as follows:
output "web_app_url" {
  description = "HTTPS URL of the web app."
  value       = "https://${azurerm_linux_web_app.app.default_hostname}"
}

output "web_app_name" {
  description = "Globally unique name of the deployed web app."
  value       = azurerm_linux_web_app.app.name
}

output "app_insights_name" {
  description = "Application Insights resource name."
  value       = azurerm_application_insights.app.name
}

output "log_analytics_workspace_name" {
  description = "Log Analytics workspace name."
  value       = azurerm_log_analytics_workspace.app.name
}

output "app_service_plan_id" {
  description = "App Service Plan ID targeted by the autoscale setting."
  value       = azurerm_service_plan.app.id
}

Terraform Root directory  code


Now in the root folder you can call the module content 

1.  main.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.7"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }
}

provider "azurerm" {
  features {}
}

module "app_service" {
  source = "./modules/app_service"

  location               = var.location
  resource_group_name    = var.resource_group_name
  app_source_dir         = "${path.root}/app"
  app_name_prefix        = var.app_name_prefix
  web_app_name_prefix    = var.web_app_name_prefix
  resource_suffix        = var.resource_suffix
  service_plan_sku       = var.service_plan_sku
  min_instance_count     = var.min_instance_count
  default_instance_count = var.default_instance_count
  max_instance_count     = var.max_instance_count
}

2. variables.tf

variable "location" {
  description = "Azure region for the resources."
  type        = string
  default     = "southindia"
}

variable "resource_group_name" {
  description = "Name of the resource group to create."
  type        = string
  default     = "rg-appservice-autoscale-demo"
}

variable "app_name_prefix" {
  description = "Prefix used for the App Service Plan and monitoring resource names."
  type        = string
  default     = "appservice"
}

variable "web_app_name_prefix" {
  description = "Prefix used for the globally unique Web App name."
  type        = string
  default     = "web"
}

variable "resource_suffix" {
  description = "Optional six-character suffix for resource names; leave null to generate one."
  type        = string
  default     = "9equss"
  nullable    = true
}

variable "service_plan_sku" {
  description = "SKU for the Linux App Service Plan."
  type        = string
  default     = "S1"
}

variable "min_instance_count" {
  description = "Minimum number of App Service Plan instances."
  type        = number
  default     = 1
}

variable "default_instance_count" {
  description = "Default number of App Service Plan instances."
  type        = number
  default     = 1
}

variable "max_instance_count" {
  description = "Maximum number of App Service Plan instances."
  type        = number
  default     = 3
}

3. output.tf

output "web_app_url" {
  description = "HTTPS URL of the sample web app."
  value       = module.app_service.web_app_url
}

output "web_app_name" {
  description = "Globally unique name of the deployed web app."
  value       = module.app_service.web_app_name
}

output "app_insights_name" {
  description = "Application Insights resource for application telemetry."
  value       = module.app_service.app_insights_name
}

output "log_analytics_workspace_name" {
  description = "Workspace receiving Application Insights telemetry."
  value       = module.app_service.log_analytics_workspace_name
}

output "app_service_plan_id" {
  description = "App Service Plan ID targeted by the autoscale setting."
  value       = module.app_service.app_service_plan_id
}

Create Node.js application

Simple web application with Node.js with port 8080 as exposing.

const { useAzureMonitor } = require("@azure/monitor-opentelemetry");
const http = require("node:http");

useAzureMonitor();

const server = http.createServer((request, response) => {
  if (request.url === "/health") {
    response.writeHead(200, { "content-type": "text/plain" });
    response.end("Super healthy");
    return;
  }

  if (request.url === "/work") {
    const finishAt = Date.now() + 500;
    let value = 0;
    while (Date.now() < finishAt) {
      value = Math.sqrt(value + Math.random());
    }
    response.writeHead(200, { "content-type": "text/plain" });
    response.end(`Work completed: ${value.toFixed(2)}`);
    return;
  }

  response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
  response.end(
    "Vybhava App Service autoscale sample" +
      "

Vybhava App Service autoscale sample

" + "

Application Insights is collecting request telemetry.

" + "

Visit /work to generate a brief CPU workload.

", ); }); const port = Number(process.env.PORT || 8080); server.listen(port);
Meta data about the app can be passed with JSON file. package.json
{
  "name": "appservice-autoscale-sample",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "@azure/monitor-opentelemetry": "^1.8.0"
  }
}

Leave a wide gap between the scale-out threshold (70) and the scale-in threshold (30), and use a longer scale-in window. Otherwise the plan flaps between instance counts.


Test the Web App from Web browser


Install Load test CLI tool 'hey'

hey is a single Go binary, so you only need to get it onto your machine and onto your PATH. Most of our Indian learners uses Windows system, so let's see command set that works on Powershell.
winget install GoLang.Go

# close and reopen PowerShell, then:
go install github.com/rakyll/hey@latest

# add Go's bin folder to PATH permanently
[Environment]::SetEnvironmentVariable("Path", $env:Path + ";$(go env GOPATH)\bin", "User")

# check the installation 
hey -h

Deploy and test application

terraform init
terraform apply

# Deploy a zip of your app
az webapp deploy --resource-group rg-webapp-demo \
  --name  --src-path app.zip --type zip

# Generate load (for example with hey or ab)
hey -z 5m -c 50 https://.azurewebsites.net
# Watch instance count
az monitor autoscale show -g rg-webapp-demo -n autoscale-webapp-demo

az login
az account set --subscription ""
terraform init
terraform plan
terraform apply

The module exports the Web App URL and name, Application Insights and Log Analytics names, and App Service Plan ID. Each instance packages the supplied app source directory as a ZIP for deployment. Use distinct resource groups and name prefixes for separate module instances.


Application Insights uses the Log Analytics workspace for request and dependency telemetry. To inspect scale activity, open the App Service Plan's Metrics in the Azure portal and view CPU Percentage; review scale operations under Autoscale or the resource group's **Activity log**.

This example uses the App Service public endpoint directly; it does not create Azure Front Door or a separate load balancer. Standard S1 App Service Plan instances incur ongoing Azure charges, so run `terraform destroy` when the environment is no longer needed.

Comments

Popular Articles

Ansible URI Module Tutorial: Real-World Application Health Checks, REST API Validation and DevOps Automation

DevOps Weapons

Ansible Jinja Templates Explained with Real world examples