Deploy Microservices on Azure with Terraform

Build a modular microservices system on Azure using reusable Terraform modules.

Introduction

30 Second Summary

Think about how apps like Netflix or Uber work behind the scenes. They aren't one giant program. They're made of dozens of independent services, each handling a single job so teams can ship updates without touching anything else.

In this project, you will deploy a product catalog microservices system on Azure using Terraform. You'll create two independently deployed services, connect them to a shared database, and expose them through a single API gateway.

What You'll Build

A microservices architecture with two Azure Functions apps (Products API and Orders API), an Azure Cosmos DB database, and an Azure API Management gateway, all provisioned using reusable Terraform modules.

By the end of this project, you'll have:

  • Two independently deployed Function Apps (Products API and Orders API) provisioned by calling the same Terraform module twice with different parameters.
  • Azure API Management configured as a unified gateway routing traffic to both backend Function Apps.
  • Azure Cosmos DB storing data with provisioned throughput and autoscale configured via a module variable.
  • Secret Mission: Configure Cosmos DB autoscale throughput by changing a single variable in terraform.tfvars and verify scaling behavior under simulated load.

Are there any prerequisites?

This project assumes you already have an Azure pay-as-you-go subscription, VS Code, the Azure CLI, and Terraform installed. You'll install Node.js and Azure Functions Core Tools in Step 1.

Before We Start

You are about to deploy a microservices system on Azure using Terraform.

Before writing any infrastructure code, you need the right tools installed locally. In this step, you will set up Node.js (the runtime for your Azure Functions), Azure Functions Core Tools (the CLI for building and deploying serverless functions), and verify your existing Azure CLI and Terraform installations.

In this step, get ready to:
  • Install Node.js and Azure Functions Core Tools.
  • Log in to Azure and verify Terraform.
  • Create the project directory structure.
Install Node.js 20 LTS

Azure Functions uses Node.js as its runtime for JavaScript functions. You need version 20 (the current LTS release) installed on your machine.

  • Check if Node.js is already installed by running this command:
node --version

✔️ I see version 20 or higher

Node.js is ready. Move on to checking npm below.

ⓧ I see an older version

You have an older Node.js version that will not work with Azure Functions v4. You need to upgrade to version 20.

  • Visit https://nodejs.org and download the 20 LTS installer for your operating system.
  • Run the installer and accept the default settings.
  • Close and reopen your terminal, then run node --version again to confirm it now shows v20 or higher.

ⓧ Command not found

Node.js is not installed on your machine yet.

  • Visit https://nodejs.org and download the 20 LTS installer for your operating system.
  • Run the installer and accept the default settings.
  • Close and reopen your terminal, then run node --version to confirm it shows v20 or higher.
  • Verify npm (Node's package manager) is also available by running:
npm --version

You should see a version number printed in your terminal.

Install Azure Functions Core Tools

Azure Functions Core Tools provides the func CLI that you will use to create, run, and deploy your serverless functions locally.

  • Check if Azure Functions Core Tools is already installed by running:
func --version

✔️ I see version 4.12 or higher

Azure Functions Core Tools is ready. Move on to the next substep.

ⓧ I see an older version

You need version 4.12 or later. Follow the instructions for your operating system to upgrade.

macOS

  • Upgrade Azure Functions Core Tools using Homebrew by running these commands:
brew tap azure/functions
brew install azure-functions-core-tools@4
brew link --overwrite azure-functions-core-tools@4
  • Run func --version again to confirm it now shows 4.12 or higher.

Windows

  • Open Add or Remove Programs in Windows Settings and uninstall any existing Azure Functions Core Tools entry.
  • Download the v4.x Windows 64-bit MSI installer.
  • Run the downloaded MSI and follow the prompts to install.
  • Close and reopen your terminal, then run func --version to confirm it shows 4.12 or higher.

ⓧ Command not found

Azure Functions Core Tools is not installed yet. Follow the instructions for your operating system.

macOS

  • Install Azure Functions Core Tools using Homebrew by running these commands:
brew tap azure/functions
brew install azure-functions-core-tools@4
  • Run func --version to confirm it shows 4.12 or higher.

Windows

  • Download the v4.x Windows 64-bit MSI installer.
  • Run the downloaded MSI and follow the prompts to install.
  • Close and reopen your terminal, then run func --version to confirm it shows 4.12 or higher.

func command still not found after installing?

Make sure you closed and reopened your terminal after the install. On macOS, confirm Homebrew's bin directory is in your PATH. On Windows, restart VS Code completely to pick up the new environment variables.

help me troubleshoot why func is not found after installing Azure Functions Core Tools

Log in to Azure and verify Terraform

Your Azure CLI and Terraform are already installed. Now you need to authenticate with Azure and confirm everything is working.

  • Log in to Azure by running:
az login

What does this command do?

This opens your browser and prompts you to sign in with your Azure account. Once authenticated, the CLI stores a token locally so all future az commands work against your subscription.

  • Verify your subscription is active by running:
az account show

You should see JSON output with your subscription name and ID. Confirm the state field says Enabled.

  • Verify Terraform is available by running:
terraform --version

You should see a version number printed. Any recent version of Terraform will work for this project.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project directory structure by running these commands:
mkdir microservices-azure && cd microservices-azure && mkdir -p infra/modules/cosmos-db infra/modules/functions infra/modules/api-management
  • Confirm the directories exist by running:
ls infra/modules

You should see api-management, cosmos-db, and functions listed in the output.

Why this directory structure?

Each subfolder under infra/modules/ holds a reusable Terraform module. This pattern lets you define infrastructure once and instantiate it multiple times with different parameters.

You will call the functions module twice (once for the Products API, once for the Orders API) to demonstrate true module reuse.

az login opens the wrong subscription?

If you have multiple Azure subscriptions, the CLI may default to a different one. Set the correct subscription by running az account set --subscription "Your Subscription Name" with the name shown in your Azure Portal.

help me select the correct Azure subscription for my project

Your environment is ready. Next up, you will write the Terraform modules that define your entire microservices infrastructure as code.

Write the Terraform Modules

Your project directory is ready with all the module folders in place. Now you need to fill those folders with actual Terraform configuration that describes your entire microservices infrastructure.

The power of Terraform modules is reusability. You will write a Functions module once, then call it twice with different parameters to create two completely independent microservices.

In this step, get ready to:
  • Create a Cosmos DB module with parameterized throughput and autoscale support.
  • Create a Functions module designed to be instantiated once per microservice.
  • Create an API Management module and the root configuration that wires everything together.
Create the Cosmos DB module

The Cosmos DB module provisions your database layer. It uses a dynamic block to conditionally enable autoscale, which you will toggle later in the Secret Mission.

  • In VS Code, open the file infra/modules/cosmos-db/variables.tf and paste the following code:
variable "resource_group_name" {
  description = "Name of the resource group"
  type        = string
}

variable "location" {
  description = "Azure region"
  type        = string
}

variable "account_name" {
  description = "Cosmos DB account name"
  type        = string
}

variable "database_name" {
  description = "Cosmos DB SQL database name"
  type        = string
}

variable "container_names" {
  description = "List of container names to create"
  type        = list(string)
}

variable "throughput" {
  description = "Manual throughput (RU/s). Ignored if enable_autoscale is true."
  type        = number
  default     = 1000
}

variable "enable_autoscale" {
  description = "Use autoscale throughput instead of manual"
  type        = bool
  default     = false
}

variable "max_throughput" {
  description = "Max autoscale throughput (RU/s)"
  type        = number
  default     = 4000
}

What does this code do?

These variables define the inputs your Cosmos DB module accepts. The container_names variable is a list, so you can create multiple containers from one module call.

The enable_autoscale and max_throughput variables give you a switch to toggle between manual and autoscale throughput without editing the module itself.

  • Save variables.tf.
  • Create infra/modules/cosmos-db/main.tf and paste the following code:
resource "azurerm_cosmosdb_account" "this" {
  name                = var.account_name
  location            = var.location
  resource_group_name = var.resource_group_name
  offer_type          = "Standard"
  kind                = "GlobalDocumentDB"
  free_tier_enabled   = true

  consistency_policy {
    consistency_level = "Session"
  }

  geo_location {
    location          = var.location
    failover_priority = 0
  }
}

resource "azurerm_cosmosdb_sql_database" "this" {
  name                = var.database_name
  resource_group_name = var.resource_group_name
  account_name        = azurerm_cosmosdb_account.this.name

  dynamic "autoscale_settings" {
    for_each = var.enable_autoscale ? [1] : []
    content {
      max_throughput = var.max_throughput
    }
  }

  throughput = var.enable_autoscale ? null : var.throughput
}

resource "azurerm_cosmosdb_sql_container" "this" {
  for_each = toset(var.container_names)

  name                = each.value
  resource_group_name = var.resource_group_name
  account_name        = azurerm_cosmosdb_account.this.name
  database_name       = azurerm_cosmosdb_sql_database.this.name
  partition_key_paths = ["/id"]
}

What does this code do?

This creates three resources: the Cosmos DB account, a SQL database, and one or more containers.

  • free_tier_enabled = true opts into the lifetime free tier (1000 RU/s + 25 GB).
  • The dynamic "autoscale_settings" block only appears when enable_autoscale is true. Otherwise, manual throughput is used.
  • for_each = toset(var.container_names) creates one container per name in the list, so one module call produces multiple containers.
  • Save main.tf.
  • Create infra/modules/cosmos-db/outputs.tf and paste the following code:
output "connection_string" {
  value     = azurerm_cosmosdb_account.this.primary_sql_connection_string
  sensitive = true
}

output "account_name" {
  value = azurerm_cosmosdb_account.this.name
}

output "database_name" {
  value = azurerm_cosmosdb_sql_database.this.name
}

What does this code do?

Outputs expose values from this module to the parent configuration. The connection_string is marked sensitive so Terraform won't print it in plain text during plan or apply.

The root configuration will pass this connection string into the Functions modules so each microservice can connect to the database.

  • Save outputs.tf.
  • Confirm all three files exist by running this command from your project root:
ls infra/modules/cosmos-db/

You should see main.tf, outputs.tf, and variables.tf listed.

Don't see all three files?

  • Make sure you saved each file after pasting the code.
  • Confirm the files are inside infra/modules/cosmos-db/, not in a parent or sibling directory.

Help me troubleshoot my Cosmos DB module file structure.

Create the Functions module

The Functions module is the heart of module reuse in this project. You will call this same module twice, once for the Products API and once for the Orders API, passing different names and storage accounts each time.

  • Create infra/modules/functions/variables.tf and paste the following code:
variable "resource_group_name" {
  description = "Name of the resource group"
  type        = string
}

variable "location" {
  description = "Azure region"
  type        = string
}

variable "function_app_name" {
  description = "Name of the Function App"
  type        = string
}

variable "storage_account_name" {
  description = "Storage account name for the Function App"
  type        = string
}

variable "node_version" {
  description = "Node.js runtime version"
  type        = string
  default     = "20"
}

variable "cosmos_connection_string" {
  description = "Cosmos DB connection string"
  type        = string
  sensitive   = true
}

What does this code do?

Each variable becomes a parameter you pass in when calling this module. The cosmos_connection_string is marked sensitive to prevent it from leaking into Terraform's console output.

By parameterizing function_app_name and storage_account_name, you can reuse this module for any number of microservices without editing the module code.

  • Save variables.tf.
  • Create infra/modules/functions/main.tf and paste the following code:
resource "azurerm_storage_account" "this" {
  name                     = var.storage_account_name
  resource_group_name      = var.resource_group_name
  location                 = var.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

resource "azurerm_service_plan" "this" {
  name                = "${var.function_app_name}-plan"
  resource_group_name = var.resource_group_name
  location            = var.location
  os_type             = "Linux"
  sku_name            = "Y1"
}

resource "azurerm_linux_function_app" "this" {
  name                = var.function_app_name
  resource_group_name = var.resource_group_name
  location            = var.location

  storage_account_name       = azurerm_storage_account.this.name
  storage_account_access_key = azurerm_storage_account.this.primary_access_key
  service_plan_id            = azurerm_service_plan.this.id

  site_config {
    application_stack {
      node_version = var.node_version
    }
  }

  app_settings = {
    "COSMOS_CONNECTION_STRING" = var.cosmos_connection_string
  }
}

What does this code do?

This module creates three Azure resources that together form one serverless microservice:

  • A storage account that Azure Functions uses internally to manage state and logs.
  • A service plan with sku_name = "Y1" (the Consumption tier). This means you only pay when your function executes.
  • The Linux Function App itself, configured with Node.js 20 and the Cosmos DB connection string injected as an app setting.

Why module reuse instead of copy-paste?

Without modules, you would copy-paste these three resources for each microservice and manually change names everywhere. With a parameterized module, you call it twice with different variables and Terraform handles the rest.

If you later need to change the Node.js version or add monitoring, you update one module and both services pick up the change on the next terraform apply.

  • Save main.tf.
  • Create infra/modules/functions/outputs.tf and paste the following code:
output "function_app_name" {
  value = azurerm_linux_function_app.this.name
}

output "function_app_url" {
  value = "https://${azurerm_linux_function_app.this.default_hostname}"
}

output "function_app_id" {
  value = azurerm_linux_function_app.this.id
}

What does this code do?

These outputs let the root configuration and other modules reference the Function App by name, URL, or ID. You will use function_app_name later when deploying your code with func azure functionapp publish.

  • Save outputs.tf.
  • Confirm all three files exist by running:
ls infra/modules/functions/

You should see main.tf, outputs.tf, and variables.tf listed.

Missing a file?

  • Check that you created the files inside infra/modules/functions/, not inside infra/modules/cosmos-db/.
  • Confirm each file is saved (look for the dot indicator on the tab in VS Code).

Help me troubleshoot my Functions module files.

Create the API Management module and root configuration

The API Management module is the simplest of the three. After creating it, you will write the root configuration that wires all modules together and defines the variable values for your deployment.

  • Create infra/modules/api-management/variables.tf and paste the following code:
variable "resource_group_name" {
  description = "Name of the resource group"
  type        = string
}

variable "location" {
  description = "Azure region"
  type        = string
}

variable "name" {
  description = "API Management instance name"
  type        = string
}

variable "publisher_name" {
  description = "Publisher organization name"
  type        = string
}

variable "publisher_email" {
  description = "Publisher email for notifications"
  type        = string
}

What does this code do?

These five variables are the minimum inputs Azure API Management needs: a name, location, resource group, and publisher details for notifications.

  • Save variables.tf.
  • Create infra/modules/api-management/main.tf and paste the following code:
resource "azurerm_api_management" "this" {
  name                = var.name
  location            = var.location
  resource_group_name = var.resource_group_name
  publisher_name      = var.publisher_name
  publisher_email     = var.publisher_email

  sku_name = "Consumption_0"
}

What does this code do?

This creates an Azure API Management instance on the Consumption tier. The Consumption_0 SKU means there is no fixed monthly cost. It auto-scales and you only pay per API call (first 1 million calls per subscription are free).

  • Save main.tf.
  • Create infra/modules/api-management/outputs.tf and paste the following code:
output "gateway_url" {
  value = azurerm_api_management.this.gateway_url
}

output "id" {
  value = azurerm_api_management.this.id
}

What does this code do?

The gateway_url output gives you the single public URL that fronts all your microservices. You will use this URL later to test both the Products and Orders APIs through one gateway.

  • Save outputs.tf.
  • Confirm the API Management module files exist by running:
ls infra/modules/api-management/

You should see main.tf, outputs.tf, and variables.tf listed.

Now create the root configuration that calls all three modules.

  • Create infra/main.tf and paste the following code:
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

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

module "cosmos_db" {
  source = "./modules/cosmos-db"

  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  account_name        = var.cosmos_account_name
  database_name       = var.cosmos_database_name
  container_names     = var.cosmos_container_names
  throughput          = var.cosmos_throughput
  enable_autoscale    = var.cosmos_enable_autoscale
  max_throughput      = var.cosmos_max_throughput
}

module "products_function" {
  source = "./modules/functions"

  resource_group_name     = azurerm_resource_group.main.name
  location                = azurerm_resource_group.main.location
  function_app_name       = var.products_function_app_name
  storage_account_name    = var.products_storage_account_name
  node_version            = var.node_version
  cosmos_connection_string = module.cosmos_db.connection_string
}

module "orders_function" {
  source = "./modules/functions"

  resource_group_name     = azurerm_resource_group.main.name
  location                = azurerm_resource_group.main.location
  function_app_name       = var.orders_function_app_name
  storage_account_name    = var.orders_storage_account_name
  node_version            = var.node_version
  cosmos_connection_string = module.cosmos_db.connection_string
}

module "api_management" {
  source = "./modules/api-management"

  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  name                = var.apim_name
  publisher_name      = var.apim_publisher_name
  publisher_email     = var.apim_publisher_email
}

What does this code do?

This is the root configuration that orchestrates all your modules. It creates one resource group, then calls each module inside it.

  • The cosmos_db module is called once to create the shared database.
  • The functions module is called twice (products_function and orders_function) with different names and storage accounts. Each gets its own independent Function App.
  • Both function modules receive module.cosmos_db.connection_string so they can connect to the same database.
  • The api_management module creates the unified gateway in front of both services.
  • Save infra/main.tf.
  • Create infra/variables.tf and paste the following code:
variable "resource_group_name" {
  description = "Name of the resource group"
  type        = string
}

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

variable "cosmos_account_name" {
  description = "Name of the Cosmos DB account"
  type        = string
}

variable "cosmos_database_name" {
  description = "Name of the Cosmos DB SQL database"
  type        = string
}

variable "cosmos_container_names" {
  description = "List of Cosmos DB container names to create"
  type        = list(string)
}

variable "cosmos_throughput" {
  description = "Manual throughput (RU/s) for the database. Ignored if enable_autoscale is true."
  type        = number
  default     = 1000
}

variable "cosmos_enable_autoscale" {
  description = "Enable autoscale throughput instead of manual"
  type        = bool
  default     = false
}

variable "cosmos_max_throughput" {
  description = "Max autoscale throughput (RU/s). Only used when enable_autoscale is true."
  type        = number
  default     = 4000
}

variable "products_function_app_name" {
  description = "Name of the Products Function App"
  type        = string
}

variable "products_storage_account_name" {
  description = "Storage account name for the Products Function App"
  type        = string
}

variable "orders_function_app_name" {
  description = "Name of the Orders Function App"
  type        = string
}

variable "orders_storage_account_name" {
  description = "Storage account name for the Orders Function App"
  type        = string
}

variable "node_version" {
  description = "Node.js version for the Function App runtime"
  type        = string
  default     = "20"
}

variable "apim_name" {
  description = "Name of the API Management instance"
  type        = string
}

variable "apim_publisher_name" {
  description = "Publisher name for APIM"
  type        = string
}

variable "apim_publisher_email" {
  description = "Publisher email for APIM notifications"
  type        = string
}

What does this code do?

These root-level variables are the "interface" for your entire deployment. You set their values once in terraform.tfvars and Terraform distributes them to the appropriate modules.

Notice how products_function_app_name and orders_function_app_name are separate variables. This is what lets you call the same Functions module twice with different identities.

  • Save infra/variables.tf.
  • Create infra/outputs.tf and paste the following code:
output "products_function_app_name" {
  value = module.products_function.function_app_name
}

output "products_function_app_url" {
  value = module.products_function.function_app_url
}

output "orders_function_app_name" {
  value = module.orders_function.function_app_name
}

output "orders_function_app_url" {
  value = module.orders_function.function_app_url
}

output "apim_gateway_url" {
  value = module.api_management.gateway_url
}

output "cosmos_connection_string" {
  value     = module.cosmos_db.connection_string
  sensitive = true
}

What does this code do?

Root outputs surface important values after terraform apply completes. You will use products_function_app_name and orders_function_app_name when deploying your code. The apim_gateway_url is the single URL you will test against.

  • Save infra/outputs.tf.
  • Create infra/terraform.tfvars and paste the following code:
resource_group_name           = "rg-microservices"
location                      = "eastus"
cosmos_account_name           = "cosmosdb-microservices"
cosmos_database_name          = "microservices-db"
cosmos_container_names        = ["products", "orders"]
cosmos_throughput             = 1000
cosmos_enable_autoscale       = false
cosmos_max_throughput         = 4000
products_function_app_name    = "func-products-api"
products_storage_account_name = "stproductsfunc"
orders_function_app_name      = "func-orders-api"
orders_storage_account_name   = "stordersfunc"
node_version                  = "20"
apim_name                     = "apim-microservices"
apim_publisher_name           = "Microservices Project"
apim_publisher_email          = "admin@example.com"

What does this code do?

This file supplies concrete values for every variable. Terraform automatically loads terraform.tfvars when you run plan or apply, so you never need to type these values manually.

Notice cosmos_enable_autoscale = false. The database starts with manual throughput at 1000 RU/s. In the Secret Mission, you will flip this to true and watch Terraform reconfigure the database.

  • Save infra/terraform.tfvars.
  • Confirm all root configuration files exist by running:
ls infra/*.tf infra/*.tfvars

You should see infra/main.tf, infra/outputs.tf, infra/variables.tf, and infra/terraform.tfvars listed.

Files not showing up?

  • Make sure the root files (main.tf, variables.tf, outputs.tf, terraform.tfvars) are inside infra/ directly, not inside one of the module subdirectories.
  • Check that you used .tf as the file extension (not .txt or .hcl).
  • Confirm the terraform.tfvars filename is exact. Terraform only auto-loads files named terraform.tfvars or *.auto.tfvars.

Help me fix my root Terraform configuration file structure.

✔️ Awesome, I've got everything!

Great. Double check you have saved all 13 files across the three module directories and the root infra/ folder.

ⓧ I'd like to double check the full code

Here is every file you should have at this point. Compare your code against these references.

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

variable "location" {
  description = "Azure region"
  type        = string
}

variable "account_name" {
  description = "Cosmos DB account name"
  type        = string
}

variable "database_name" {
  description = "Cosmos DB SQL database name"
  type        = string
}

variable "container_names" {
  description = "List of container names to create"
  type        = list(string)
}

variable "throughput" {
  description = "Manual throughput (RU/s). Ignored if enable_autoscale is true."
  type        = number
  default     = 1000
}

variable "enable_autoscale" {
  description = "Use autoscale throughput instead of manual"
  type        = bool
  default     = false
}

variable "max_throughput" {
  description = "Max autoscale throughput (RU/s)"
  type        = number
  default     = 4000
}
resource "azurerm_cosmosdb_account" "this" {
  name                = var.account_name
  location            = var.location
  resource_group_name = var.resource_group_name
  offer_type          = "Standard"
  kind                = "GlobalDocumentDB"
  free_tier_enabled   = true

  consistency_policy {
    consistency_level = "Session"
  }

  geo_location {
    location          = var.location
    failover_priority = 0
  }
}

resource "azurerm_cosmosdb_sql_database" "this" {
  name                = var.database_name
  resource_group_name = var.resource_group_name
  account_name        = azurerm_cosmosdb_account.this.name

  dynamic "autoscale_settings" {
    for_each = var.enable_autoscale ? [1] : []
    content {
      max_throughput = var.max_throughput
    }
  }

  throughput = var.enable_autoscale ? null : var.throughput
}

resource "azurerm_cosmosdb_sql_container" "this" {
  for_each = toset(var.container_names)

  name                = each.value
  resource_group_name = var.resource_group_name
  account_name        = azurerm_cosmosdb_account.this.name
  database_name       = azurerm_cosmosdb_sql_database.this.name
  partition_key_paths = ["/id"]
}
output "connection_string" {
  value     = azurerm_cosmosdb_account.this.primary_sql_connection_string
  sensitive = true
}

output "account_name" {
  value = azurerm_cosmosdb_account.this.name
}

output "database_name" {
  value = azurerm_cosmosdb_sql_database.this.name
}
variable "resource_group_name" {
  description = "Name of the resource group"
  type        = string
}

variable "location" {
  description = "Azure region"
  type        = string
}

variable "function_app_name" {
  description = "Name of the Function App"
  type        = string
}

variable "storage_account_name" {
  description = "Storage account name for the Function App"
  type        = string
}

variable "node_version" {
  description = "Node.js runtime version"
  type        = string
  default     = "20"
}

variable "cosmos_connection_string" {
  description = "Cosmos DB connection string"
  type        = string
  sensitive   = true
}
resource "azurerm_storage_account" "this" {
  name                     = var.storage_account_name
  resource_group_name      = var.resource_group_name
  location                 = var.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

resource "azurerm_service_plan" "this" {
  name                = "${var.function_app_name}-plan"
  resource_group_name = var.resource_group_name
  location            = var.location
  os_type             = "Linux"
  sku_name            = "Y1"
}

resource "azurerm_linux_function_app" "this" {
  name                = var.function_app_name
  resource_group_name = var.resource_group_name
  location            = var.location

  storage_account_name       = azurerm_storage_account.this.name
  storage_account_access_key = azurerm_storage_account.this.primary_access_key
  service_plan_id            = azurerm_service_plan.this.id

  site_config {
    application_stack {
      node_version = var.node_version
    }
  }

  app_settings = {
    "COSMOS_CONNECTION_STRING" = var.cosmos_connection_string
  }
}
output "function_app_name" {
  value = azurerm_linux_function_app.this.name
}

output "function_app_url" {
  value = "https://${azurerm_linux_function_app.this.default_hostname}"
}

output "function_app_id" {
  value = azurerm_linux_function_app.this.id
}
variable "resource_group_name" {
  description = "Name of the resource group"
  type        = string
}

variable "location" {
  description = "Azure region"
  type        = string
}

variable "name" {
  description = "API Management instance name"
  type        = string
}

variable "publisher_name" {
  description = "Publisher organization name"
  type        = string
}

variable "publisher_email" {
  description = "Publisher email for notifications"
  type        = string
}
resource "azurerm_api_management" "this" {
  name                = var.name
  location            = var.location
  resource_group_name = var.resource_group_name
  publisher_name      = var.publisher_name
  publisher_email     = var.publisher_email

  sku_name = "Consumption_0"
}
output "gateway_url" {
  value = azurerm_api_management.this.gateway_url
}

output "id" {
  value = azurerm_api_management.this.id
}
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

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

module "cosmos_db" {
  source = "./modules/cosmos-db"

  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  account_name        = var.cosmos_account_name
  database_name       = var.cosmos_database_name
  container_names     = var.cosmos_container_names
  throughput          = var.cosmos_throughput
  enable_autoscale    = var.cosmos_enable_autoscale
  max_throughput      = var.cosmos_max_throughput
}

module "products_function" {
  source = "./modules/functions"

  resource_group_name     = azurerm_resource_group.main.name
  location                = azurerm_resource_group.main.location
  function_app_name       = var.products_function_app_name
  storage_account_name    = var.products_storage_account_name
  node_version            = var.node_version
  cosmos_connection_string = module.cosmos_db.connection_string
}

module "orders_function" {
  source = "./modules/functions"

  resource_group_name     = azurerm_resource_group.main.name
  location                = azurerm_resource_group.main.location
  function_app_name       = var.orders_function_app_name
  storage_account_name    = var.orders_storage_account_name
  node_version            = var.node_version
  cosmos_connection_string = module.cosmos_db.connection_string
}

module "api_management" {
  source = "./modules/api-management"

  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  name                = var.apim_name
  publisher_name      = var.apim_publisher_name
  publisher_email     = var.apim_publisher_email
}
variable "resource_group_name" {
  description = "Name of the resource group"
  type        = string
}

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

variable "cosmos_account_name" {
  description = "Name of the Cosmos DB account"
  type        = string
}

variable "cosmos_database_name" {
  description = "Name of the Cosmos DB SQL database"
  type        = string
}

variable "cosmos_container_names" {
  description = "List of Cosmos DB container names to create"
  type        = list(string)
}

variable "cosmos_throughput" {
  description = "Manual throughput (RU/s) for the database. Ignored if enable_autoscale is true."
  type        = number
  default     = 1000
}

variable "cosmos_enable_autoscale" {
  description = "Enable autoscale throughput instead of manual"
  type        = bool
  default     = false
}

variable "cosmos_max_throughput" {
  description = "Max autoscale throughput (RU/s). Only used when enable_autoscale is true."
  type        = number
  default     = 4000
}

variable "products_function_app_name" {
  description = "Name of the Products Function App"
  type        = string
}

variable "products_storage_account_name" {
  description = "Storage account name for the Products Function App"
  type        = string
}

variable "orders_function_app_name" {
  description = "Name of the Orders Function App"
  type        = string
}

variable "orders_storage_account_name" {
  description = "Storage account name for the Orders Function App"
  type        = string
}

variable "node_version" {
  description = "Node.js version for the Function App runtime"
  type        = string
  default     = "20"
}

variable "apim_name" {
  description = "Name of the API Management instance"
  type        = string
}

variable "apim_publisher_name" {
  description = "Publisher name for APIM"
  type        = string
}

variable "apim_publisher_email" {
  description = "Publisher email for APIM notifications"
  type        = string
}
output "products_function_app_name" {
  value = module.products_function.function_app_name
}

output "products_function_app_url" {
  value = module.products_function.function_app_url
}

output "orders_function_app_name" {
  value = module.orders_function.function_app_name
}

output "orders_function_app_url" {
  value = module.orders_function.function_app_url
}

output "apim_gateway_url" {
  value = module.api_management.gateway_url
}

output "cosmos_connection_string" {
  value     = module.cosmos_db.connection_string
  sensitive = true
}
resource_group_name           = "rg-microservices"
location                      = "eastus"
cosmos_account_name           = "cosmosdb-microservices"
cosmos_database_name          = "microservices-db"
cosmos_container_names        = ["products", "orders"]
cosmos_throughput             = 1000
cosmos_enable_autoscale       = false
cosmos_max_throughput         = 4000
products_function_app_name    = "func-products-api"
products_storage_account_name = "stproductsfunc"
orders_function_app_name      = "func-orders-api"
orders_storage_account_name   = "stordersfunc"
node_version                  = "20"
apim_name                     = "apim-microservices"
apim_publisher_name           = "Microservices Project"
apim_publisher_email          = "admin@example.com"

All your infrastructure is defined as code. Next up, you will initialize Terraform, preview the resource plan, and deploy everything to Azure.

Deploy Infrastructure with Terraform

In the previous step, you wrote all the Terraform modules that define your microservices architecture. The configuration is complete, but nothing exists in Azure yet.

Now you will run Terraform's init-plan-apply workflow to turn those configuration files into real cloud resources. The plan step previews everything before a single resource is created, giving you a safety check before committing real infrastructure.

In this step, get ready to:
  • Initialize Terraform and review the deployment plan.
  • Apply the configuration to create all Azure resources.
  • Verify the deployed resources using Terraform outputs and the Azure Portal.
Initialize Terraform and review the plan

Before Terraform can create resources, it needs to download the Azure provider plugin and register your local modules. This is what the initialization step handles.

  • Navigate into the infra directory by running this command:
cd infra
  • Initialize Terraform by running:
terraform init

You should see output showing the backend initializing, modules being set up, and the azurerm provider downloading. The final message reads: Terraform has been successfully initialized!

What does terraform init do?

This command prepares your working directory for Terraform operations. It downloads the azurerm provider (version ~> 4.0 as specified in your config), registers the three local modules (cosmos-db, functions, api-management), and creates a .terraform directory to cache everything.

It also creates a .terraform.lock.hcl file that pins the exact provider version for reproducible deployments.

Seeing a provider error?

If you see an error about the azurerm provider not being found, check that infra/main.tf has the required_providers block with source = "hashicorp/azurerm". Also confirm you are running the command from inside the infra folder, not the project root.

help me fix my terraform init error

  • Preview the resources Terraform will create by running:
terraform plan

The plan output shows approximately 12 resources to be created, including the resource group, Cosmos DB account, two Function Apps with their own storage accounts and service plans, and the API Management instance.

What should I see in the plan?

The plan lists every resource Terraform intends to create, prefixed with a + symbol. You should see resources from each module: the Cosmos DB account, database, and two containers from the cosmos-db module. Two storage accounts, two service plans, and two Function Apps from the functions module (called twice). One API Management instance from the api-management module.

At the bottom, the plan summary reads something like "Plan: 12 to add, 0 to change, 0 to destroy." The exact count may vary slightly depending on provider version.

Plan shows errors instead of resources?

Common causes include a typo in terraform.tfvars (check for missing quotes or commas), a mismatch between variable names in variables.tf and main.tf, or a module source path that does not match your folder structure. Terraform error messages include the file and line number causing the issue.

help me fix my terraform plan errors

Apply the configuration to provision resources

The plan confirmed Terraform knows what to build. Now you will apply the configuration to create every resource in Azure.

  • Deploy all resources by running:
terraform apply
  • Terraform displays the same plan again and asks for confirmation. Type yes and press Enter to proceed.

How long does deployment take?

The Cosmos DB account takes 3-5 minutes to provision because Azure sets up the global replication framework even for single-region deployments. The APIM Consumption tier deploys in under 2 minutes. Function Apps and storage accounts deploy in seconds.

The total wait is typically under 5 minutes. Terraform shows progress for each resource as it creates.

When the apply finishes, you should see a green "Apply complete!" message with a count of resources added.

Apply failed partway through?

If Cosmos DB creation fails with a naming conflict, the account name must be globally unique. Open terraform.tfvars and append a short suffix to cosmos_account_name (for example, cosmosdb-microservices-abc). Then run terraform apply again.

If you see an authorization error, confirm you are logged in by running az account show. If the session expired, run az login and try the apply again.

help me fix my terraform apply failure

Verify outputs and confirm resources in Azure Portal

Terraform stores output values that you defined in outputs.tf. These give you the Function App names and the APIM gateway URL without needing to look them up manually.

  • View all output values by running:
terraform output

You should see products_function_app_name, products_function_app_url, orders_function_app_name, orders_function_app_url, and apim_gateway_url listed. The cosmos_connection_string output is marked sensitive so it appears as <sensitive>.

  • Open the Azure Portal at portal.azure.com.
  • Search for rg-microservices in the top search bar and select the resource group.
  • Confirm you see both Function Apps (func-products-api and func-orders-api), the Cosmos DB account, the API Management instance, storage accounts, and service plans listed inside the resource group.

Why two of everything?

Each Function App has its own dedicated storage account and service plan because you called the functions module twice with different parameters. This is true microservices independence. Each service can scale, deploy, and fail independently without affecting the other.

Resources not appearing in the portal?

Make sure you are viewing the correct subscription. Click your profile icon in the top right, select Switch directory, and confirm the subscription matches what az account show returned earlier. Resources can also take a minute to appear after Terraform completes.

help me find my deployed resources in Azure Portal

Your entire infrastructure is live. Two independent Function Apps, a shared Cosmos DB database, and an API gateway are all running in Azure. Next up, you will build the microservices code and deploy it to these Function Apps.

Build and Deploy the Functions Code

Your infrastructure is live on Azure. Two empty Function Apps are waiting for code, a Cosmos DB database has containers ready for data, and an API gateway sits in front of everything.

Now you need to write the actual microservices. Each service gets its own independent codebase, its own deployment, and its own connection to the shared database. This is the core of the microservices pattern: separate code, separate deploys, shared data layer.

In this step, get ready to:
  • Create two Function App projects and write the Products and Orders API code.
  • Configure the Cosmos DB connection string and test both services locally.
  • Deploy each microservice independently to its own Function App on Azure.
Initialize the Function App projects and write the microservices code

Each microservice lives in its own project folder with its own dependencies. The Azure Functions Core Tools CLI scaffolds the project structure for you.

  • Make sure you are in your microservices-azure project root, then initialize both Function App projects by running these commands:
func init products-api --worker-runtime javascript --model V4
func init orders-api --worker-runtime javascript --model V4

What do these commands do?

Each func init command creates a new folder with the project scaffold: host.json for runtime configuration, local.settings.json for local environment variables, and package.json for Node.js dependencies.

The --model V4 flag enables the v4 programming model, which uses code-first function registration instead of separate function.json files.

  • Confirm both project folders exist by running:
ls

You should see products-api and orders-api listed alongside your infra folder.

  • Install the Cosmos DB SDK in the products-api project by running:
cd products-api
npm install @azure/cosmos
  • Create the src directory structure by running:
mkdir -p src/functions
  • Now do the same for the orders-api project by running:
cd ../orders-api
npm install @azure/cosmos
mkdir -p src/functions

npm install failing?

Make sure you have Node.js 20 installed. Run node --version to confirm. If the command is not found, revisit Step 1 to install Node.js.

help me troubleshoot npm install failures for @azure/cosmos

Now you need to replace the generated package.json in each project. The v4 model requires a single-file entry point in the main field.

  • Navigate back to the products-api folder and replace the contents of products-api/package.json with the following:
{
  "name": "products-api",
  "version": "1.0.0",
  "description": "Products microservice",
  "main": "src/index.js",
  "scripts": {
    "start": "func start"
  },
  "dependencies": {
    "@azure/functions": "^4.0.0",
    "@azure/cosmos": "^4.0.0"
  }
}

Why does the main field matter?

The main field tells the Functions runtime which file to load at startup. It must point to a single file (src/index.js) that requires your function modules.

Using a glob pattern like src/functions/*.js causes a "No HTTP triggers found" error on deployment. A single entrypoint that requires each function file is the correct pattern.

  • Create the entry point file products-api/src/index.js with this content:
require('./functions/products.js');
  • Create products-api/src/functions/products.js with the full Products API code:
const { app } = require('@azure/functions');
const { CosmosClient } = require('@azure/cosmos');

// Connect to the products container in Cosmos DB
const client = new CosmosClient(process.env.COSMOS_CONNECTION_STRING);
const database = client.database('microservices-db');
const container = database.container('products');

// GET all products
app.http('getProducts', {
    methods: ['GET'],
    authLevel: 'anonymous',
    route: 'products',
    handler: async (request, context) => {
        const { resources } = await container.items.readAll().fetchAll();
        return { jsonBody: resources };
    }
});

// POST a new product
app.http('createProduct', {
    methods: ['POST'],
    authLevel: 'anonymous',
    route: 'products',
    handler: async (request, context) => {
        const body = await request.json();
        const product = {
            id: Date.now().toString(),
            name: body.name,
            price: body.price,
            category: body.category,
            createdAt: new Date().toISOString()
        };
        const { resource } = await container.items.create(product);
        return { status: 201, jsonBody: resource };
    }
});

// GET a single product by ID
app.http('getProduct', {
    methods: ['GET'],
    authLevel: 'anonymous',
    route: 'products/{id}',
    handler: async (request, context) => {
        const id = request.params.id;
        try {
            const { resource } = await container.item(id, id).read();
            if (!resource) {
                return { status: 404, jsonBody: { error: 'Product not found' } };
            }
            return { jsonBody: resource };
        } catch (error) {
            return { status: 404, jsonBody: { error: 'Product not found' } };
        }
    }
});

What does this code do?

This file registers three HTTP-triggered functions using the v4 app.http() pattern. Each function connects to the products container in Cosmos DB using a connection string from the environment.

  • getProducts reads all products from the container.
  • createProduct accepts a POST body and creates a new product document with an auto-generated ID.
  • getProduct fetches a single product by its ID from the URL parameter.
  • Save products-api/src/functions/products.js.

Now set up the orders-api project with the same structure.

  • Replace the contents of orders-api/package.json with:
{
  "name": "orders-api",
  "version": "1.0.0",
  "description": "Orders microservice",
  "main": "src/index.js",
  "scripts": {
    "start": "func start"
  },
  "dependencies": {
    "@azure/functions": "^4.0.0",
    "@azure/cosmos": "^4.0.0"
  }
}
  • Create orders-api/src/index.js with this content:
require('./functions/orders.js');
  • Create orders-api/src/functions/orders.js with the full Orders API code:
const { app } = require('@azure/functions');
const { CosmosClient } = require('@azure/cosmos');

// Connect to the orders container in Cosmos DB
const client = new CosmosClient(process.env.COSMOS_CONNECTION_STRING);
const database = client.database('microservices-db');
const container = database.container('orders');

// GET all orders
app.http('getOrders', {
    methods: ['GET'],
    authLevel: 'anonymous',
    route: 'orders',
    handler: async (request, context) => {
        const { resources } = await container.items.readAll().fetchAll();
        return { jsonBody: resources };
    }
});

// POST a new order
app.http('createOrder', {
    methods: ['POST'],
    authLevel: 'anonymous',
    route: 'orders',
    handler: async (request, context) => {
        const body = await request.json();
        const order = {
            id: Date.now().toString(),
            productId: body.productId,
            quantity: body.quantity,
            customerName: body.customerName,
            status: 'pending',
            createdAt: new Date().toISOString()
        };
        const { resource } = await container.items.create(order);
        return { status: 201, jsonBody: resource };
    }
});

// GET a single order by ID
app.http('getOrder', {
    methods: ['GET'],
    authLevel: 'anonymous',
    route: 'orders/{id}',
    handler: async (request, context) => {
        const id = request.params.id;
        try {
            const { resource } = await container.item(id, id).read();
            if (!resource) {
                return { status: 404, jsonBody: { error: 'Order not found' } };
            }
            return { jsonBody: resource };
        } catch (error) {
            return { status: 404, jsonBody: { error: 'Order not found' } };
        }
    }
});

What does this code do?

The orders code follows the same pattern as products. It registers three HTTP functions (getOrders, createOrder, getOrder) that operate on the orders container instead.

Each order includes a productId, quantity, customerName, and a default status of pending.

  • Save orders-api/src/functions/orders.js.
  • Run npm install in both projects to ensure all dependencies match your updated package.json files:
cd ../products-api
npm install
cd ../orders-api
npm install
cd ..

✔️ Awesome, I've got everything!

Great. Double check you have saved all files in both projects.

ⓧ I'd like to double check the full code

Here is the complete products-api/src/functions/products.js:

const { app } = require('@azure/functions');
const { CosmosClient } = require('@azure/cosmos');

const client = new CosmosClient(process.env.COSMOS_CONNECTION_STRING);
const database = client.database('microservices-db');
const container = database.container('products');

app.http('getProducts', {
    methods: ['GET'],
    authLevel: 'anonymous',
    route: 'products',
    handler: async (request, context) => {
        const { resources } = await container.items.readAll().fetchAll();
        return { jsonBody: resources };
    }
});

app.http('createProduct', {
    methods: ['POST'],
    authLevel: 'anonymous',
    route: 'products',
    handler: async (request, context) => {
        const body = await request.json();
        const product = {
            id: Date.now().toString(),
            name: body.name,
            price: body.price,
            category: body.category,
            createdAt: new Date().toISOString()
        };
        const { resource } = await container.items.create(product);
        return { status: 201, jsonBody: resource };
    }
});

app.http('getProduct', {
    methods: ['GET'],
    authLevel: 'anonymous',
    route: 'products/{id}',
    handler: async (request, context) => {
        const id = request.params.id;
        try {
            const { resource } = await container.item(id, id).read();
            if (!resource) {
                return { status: 404, jsonBody: { error: 'Product not found' } };
            }
            return { jsonBody: resource };
        } catch (error) {
            return { status: 404, jsonBody: { error: 'Product not found' } };
        }
    }
});

And the complete orders-api/src/functions/orders.js:

const { app } = require('@azure/functions');
const { CosmosClient } = require('@azure/cosmos');

const client = new CosmosClient(process.env.COSMOS_CONNECTION_STRING);
const database = client.database('microservices-db');
const container = database.container('orders');

app.http('getOrders', {
    methods: ['GET'],
    authLevel: 'anonymous',
    route: 'orders',
    handler: async (request, context) => {
        const { resources } = await container.items.readAll().fetchAll();
        return { jsonBody: resources };
    }
});

app.http('createOrder', {
    methods: ['POST'],
    authLevel: 'anonymous',
    route: 'orders',
    handler: async (request, context) => {
        const body = await request.json();
        const order = {
            id: Date.now().toString(),
            productId: body.productId,
            quantity: body.quantity,
            customerName: body.customerName,
            status: 'pending',
            createdAt: new Date().toISOString()
        };
        const { resource } = await container.items.create(order);
        return { status: 201, jsonBody: resource };
    }
});

app.http('getOrder', {
    methods: ['GET'],
    authLevel: 'anonymous',
    route: 'orders/{id}',
    handler: async (request, context) => {
        const id = request.params.id;
        try {
            const { resource } = await container.item(id, id).read();
            if (!resource) {
                return { status: 404, jsonBody: { error: 'Order not found' } };
            }
            return { jsonBody: resource };
        } catch (error) {
            return { status: 404, jsonBody: { error: 'Order not found' } };
        }
    }
});

And both entry point files (products-api/src/index.js and orders-api/src/index.js):

require('./functions/products.js');
require('./functions/orders.js');
Configure the connection string and test locally

Before deploying to Azure, you can test both services locally. They need the Cosmos DB connection string to talk to your database.

  • Retrieve the Cosmos DB connection string from Terraform by running:
cd infra
terraform output -raw cosmos_connection_string

What does this command do?

The -raw flag outputs the value without quotes or formatting. Since cosmos_connection_string is marked sensitive in Terraform, this is the only way to view the actual value.

  • Copy the connection string output.
  • Navigate back to the project root by running:
cd ..
  • Open products-api/local.settings.json in your editor and replace its contents with:
{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "node",
    "COSMOS_CONNECTION_STRING": "your-cosmos-connection-string-here"
  }
}
  • Replace your-cosmos-connection-string-here with the actual connection string you copied from terraform output.
  • Do the same for orders-api/local.settings.json. Replace its contents with the same JSON structure above, using the same connection string.
  • Save both files.

Keep your connection string private

The local.settings.json file is excluded from Git by default. The .gitignore generated by func init takes care of this. Never commit this file to source control.

  • Test the products-api locally by running:
cd products-api
func start

You should see output listing your HTTP functions with URLs like http://localhost:7071/api/products.

  • Open a new terminal tab and test the products endpoint by running:
curl http://localhost:7071/api/products

You should see an empty JSON array [] since the container has no data yet. This confirms the function connects to Cosmos DB successfully.

  • Stop the products-api by pressing Ctrl+C in its terminal.
  • Test the orders-api locally by running:
cd ../orders-api
func start

You should see the same pattern with URLs like http://localhost:7071/api/orders.

  • In your second terminal tab, test the orders endpoint by running:
curl http://localhost:7071/api/orders

You should see [] confirming the orders service also connects to Cosmos DB.

  • Stop the orders-api by pressing Ctrl+C.

Seeing a connection error or 500 response?

If you see an error about COSMOS_CONNECTION_STRING, double check that local.settings.json has the correct connection string pasted in with no extra quotes or line breaks.

If the function starts but curl returns a 500 error, confirm your Cosmos DB account exists in the Azure Portal and the database name microservices-db matches what you provisioned.

help me debug my Azure Function's connection to Cosmos DB

Deploy each microservice to Azure

Both services work locally. Now you publish each one to its corresponding Function App on Azure. This is where microservices independence shines: each service deploys on its own schedule without affecting the other.

  • Deploy the products-api to its Function App by running:
cd ../products-api
func azure functionapp publish func-products-api

What does this command do?

The func azure functionapp publish command packages your local project and deploys it to the named Function App on Azure. It uploads your code, installs dependencies remotely, and activates your functions.

The COSMOS_CONNECTION_STRING is already configured as an app setting on the Function App. Terraform set this via the app_settings block, so your deployed functions can connect to Cosmos DB immediately.

After deployment completes, you should see output listing the deployed functions and their live URLs.

  • Deploy the orders-api to its Function App by running:
cd ../orders-api
func azure functionapp publish func-orders-api
  • Test the live products endpoint by running:
curl https://func-products-api.azurewebsites.net/api/products
  • Test the live orders endpoint by running:
curl https://func-orders-api.azurewebsites.net/api/orders

Both should return [] (empty arrays). This confirms your microservices are live on Azure, each running independently in its own Function App.

Deployment succeeded but getting a 404?

If the deployment output says success but your URL returns a 404, wait 1-2 minutes. Cold starts on the Consumption plan can delay the first response.

If you see "No HTTP triggers found" in the deployment output, confirm your package.json has "main": "src/index.js" and that src/index.js correctly requires your function file.

help me troubleshoot my Azure Functions deployment

Both microservices are deployed and responding independently. Next up, you will connect them through the API Management gateway to create a single unified entry point for all your services.

Configure APIM and Test End-to-End

Your two microservices are running independently in Azure. Each has its own Function App, its own storage, and its own service plan.

Right now, consumers would need to know two separate URLs to reach your Products API and Orders API. In production, you want a single gateway URL that routes traffic to the right backend. That is exactly what Azure API Management does.

In this step, get ready to:
  • Import both Function Apps into APIM as separate APIs.
  • Add a rate-limit policy to protect both APIs from traffic spikes.
  • Test end-to-end requests through the single APIM gateway URL.
Import your Function Apps into APIM

The API gateway pattern gives external consumers a single entry point. APIM discovers your Function App's HTTP triggers and creates API operations for each one automatically.

  • In the Azure Portal, search for apim-microservices and select your API Management instance.
  • In the left sidebar under APIs, select APIs.
  • Select + Add API.
  • Under Create from Azure resource, select the Function App tile.
  • Click Browse, then click Select under Configure required settings.
  • Select func-products-api from the list and click Select.
  • Ensure all three functions (getProducts, createProduct, getProduct) are checked, then click Select.
  • Click Create to import the Products API.

What just happened?

APIM detected the HTTP-triggered functions in your Function App and created API operations for each one. It also generated a host key in the Function App so APIM can authenticate requests to the backend.

  • Repeat the process for the Orders API. Click + Add API again.
  • Select Function App under Create from Azure resource.
  • Click Browse, then Select, and choose func-orders-api.
  • Ensure all three functions (getOrders, createOrder, getOrder) are checked, then click Select.
  • Click Create to import the Orders API.

You should now see both func-products-api and func-orders-api listed under your APIs in the left panel.

Don't see your Function App in the Browse list?

Make sure both Function Apps are in the same subscription as your APIM instance. Also confirm that the functions have authLevel: 'anonymous' set. APIM can only import functions with Anonymous or Function authorization level.

Help me troubleshoot why my Function App doesn't appear in the APIM import list.

Add a rate-limit policy to both APIs

Rate limiting protects your backend services from traffic spikes by capping the number of requests allowed within a time window. You will add a policy that limits callers to 10 calls per 60 seconds.

  • In the APIs panel, select func-products-api.
  • Select All operations in the operations list.
  • In the Inbound processing section, click the </> (code editor) icon to open the policy XML editor.
  • Replace the entire contents with the following policy:
<policies>
    <inbound>
        <base />
        <rate-limit calls="10" renewal-period="60" />
    </inbound>
    <backend>
        <base />
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

What does this policy do?

The rate-limit element caps callers to 10 API calls within a 60-second sliding window. When a caller exceeds the limit, APIM returns a 429 Too Many Requests response automatically.

The <base /> element inherits any policies from parent scopes. Keeping it in each section ensures global policies still apply.

  • Click Save to apply the policy to the Products API.
  • Now select func-orders-api in the APIs panel.
  • Select All operations.
  • Click the </> code editor icon in Inbound processing.
  • Replace the XML with the same policy shown above.
  • Click Save.

Both APIs now have identical rate-limiting protection.

Seeing a validation error when saving?

Make sure the XML is well-formed. Every opening tag needs a matching closing tag. The <base /> element must be self-closing (with the / before the >). Double check that rate-limit sits inside the <inbound> section, not <outbound>.

Help me fix the validation error in my APIM rate-limit policy XML.

Test the gateway end-to-end

Every APIM instance has a gateway URL where all imported APIs are accessible. Your gateway URL is shown on the APIM Overview page.

  • In the Azure Portal, navigate to the Overview page of your apim-microservices instance.
  • Copy the Gateway URL (it looks like https://apim-microservices.azure-api.net).
  • Test the Products API by creating a product through the gateway. Run this command in your terminal:
curl -X POST [[APIM_GATEWAY_URL="https://apim-microservices.azure-api.net"]]/products \
  -H "Content-Type: application/json" \
  -d '{"name": "Test Widget", "price": 9.99, "category": "electronics"}'

You should see a 201 response with the created product JSON, including an id and createdAt timestamp.

  • Test the Orders API through the same gateway by running:
curl -X POST [[APIM_GATEWAY_URL="https://apim-microservices.azure-api.net"]]/orders \
  -H "Content-Type: application/json" \
  -d '{"productId": "12345", "quantity": 2, "customerName": "Alice"}'

You should see a 201 response with the order details, including status: "pending".

Why is this significant?

Both requests went through a single gateway URL. The consumer does not need to know that Products and Orders live on completely separate Function Apps. This is the API gateway pattern in action.

  • Verify the GET endpoints work as well by running:
curl [[APIM_GATEWAY_URL="https://apim-microservices.azure-api.net"]]/products

You should see a JSON array containing the product you just created.

  • Now test rate limiting. Run 11 rapid requests to trigger the limit:
for i in $(seq 1 11); do
  curl -s -o /dev/null -w "Request $i: %{http_code}\n" [[APIM_GATEWAY_URL="https://apim-microservices.azure-api.net"]]/products
done

The first 10 requests should return 200. The 11th request should return 429, confirming your rate limit is working.

What is a 429 response?

HTTP status 429 Too Many Requests tells the caller they have exceeded the rate limit. The response includes a Retry-After header indicating how many seconds to wait before trying again.

All requests returning 200 without hitting the rate limit?

Rate limiting can take up to 10 seconds to propagate after saving. Wait a moment and run the loop again. Also confirm you saved the policy on the correct API and that All operations was selected (not a single operation).

Help me debug why my APIM rate limit is not triggering a 429 response.

Your microservices system is fully connected. Both services respond through a single gateway with rate-limiting protection. Next up, the Secret Mission will push your Cosmos DB throughput configuration further with autoscale.

Secret mission

Configure Cosmos DB Autoscale via Module Variable

Your database currently runs at a fixed 1000 RU/s. What happens when traffic spikes? In this mission, you'll flip a single Terraform variable to enable Cosmos DB autoscale, generate load against your live API, and watch Azure dynamically adjust throughput in real time.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project uses Azure free tiers for most resources, so ongoing costs are minimal. The two storage accounts for your Function Apps cost pennies per month. APIM Consumption tier and Functions only charge per-call beyond their free grants.

Cost warning

While Cosmos DB free tier, Functions Consumption plan, and APIM's first 1 million calls are all free, the two storage accounts (stproductsfunc and stordersfunc) incur a small charge (a few cents per month) as long as they exist.

If you are not planning to return to this project, choose Delete to avoid any ongoing charges.

Resources you used:

  • Resource group rg-microservices (contains all Azure resources below).
  • Azure Cosmos DB account cosmosdb-microservices (free tier, lifetime 1000 RU/s + 25 GB).
  • Two Azure Function Apps: func-products-api and func-orders-api on Consumption plans.
  • Two storage accounts: stproductsfunc and stordersfunc.
  • Azure API Management instance apim-microservices (Consumption tier).
  • Local project folder microservices-azure on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project soon.

  • Cosmos DB free tier is lifetime. You will not be charged as long as you stay within 1000 RU/s and 25 GB.
  • Function Apps on the Consumption plan cost nothing when idle (1 million free executions per month).
  • APIM Consumption tier includes 1 million free API calls per subscription.
  • The two storage accounts will cost a few cents per month.

Pause - I'll come back to this later

Since all resources are serverless or on Consumption plans, there is no process to stop. Costs are already near zero when idle.

  • Leave everything as-is. Function Apps and APIM only charge per-request.
  • When you return, your Terraform state in the infra folder still tracks all resources. You can resume from where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. Terraform makes this a single command.

  • Destroy all Azure resources by running this command from inside your microservices-azure folder:
cd infra && terraform destroy
  • When prompted, type yes and press Enter to confirm. This removes the resource group and every resource inside it.
  • Delete the local project folder. On macOS/Linux:
cd ~/Desktop && rm -rf microservices-azure
  • On Windows (PowerShell):
cd ~\Desktop
Remove-Item -Recurse -Force microservices-azure
  • Confirm everything is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see microservices-azure listed.

Nice Work!

Nice Work!

Nice work! You just designed, provisioned, and deployed a production-grade microservices system on Azure using reusable Terraform modules.

You've learned how to:

  • Structure all cloud infrastructure as reusable, parameterized Terraform modules and demonstrate module reuse by calling the same module twice with different parameters to create two independent microservices.
  • Deploy two separate Azure Function Apps (Products API and Orders API) that are independently deployable and connected to a shared Cosmos DB database.
  • Create an Azure API Management gateway that routes and rate-limits traffic to both backend services through a single unified endpoint.
  • (Secret Mission) Changed a single variable in terraform.tfvars to enable Cosmos DB autoscale and verified dynamic scaling under load using Azure Monitor Metrics.

Ready to quiz yourself?