Event-Driven Order Pipeline on Azure
Build a serverless order pipeline with Azure Functions, Cosmos DB, and Terraform.
Introduction
30 Second Summary
When you order something online, a chain of events fires behind the scenes. Your payment triggers inventory checks, shipping labels, and confirmation emails without anyone pressing a button.
In this project, you will build an event-driven order processing pipeline on Azure using serverless functions, a NoSQL database, and Infrastructure as Code.
What You'll Build
An HTTP endpoint that accepts orders, writes them to Cosmos DB, and automatically triggers a processing function via the Change Feed.
By the end of this project, you'll have:
- A Terraform configuration provisioning the full Azure environment with managed identity and RBAC (zero hardcoded secrets).
- Two Python Azure Functions (an HTTP trigger and a Change Feed trigger) that process orders automatically.
- A GitHub Actions CI/CD pipeline that deploys both infrastructure and function code on every push to main.
- Secret Mission: Add an HTTP GET endpoint that queries all processed orders from Cosmos DB using an input binding.
Are there any prerequisites?
You'll need an Azure account with an active subscription, a GitHub account, and Terraform installed on your machine. Basic familiarity with Terraform HCL syntax is expected.
Before We Start
You are about to build an event-driven order processing pipeline on Azure. An HTTP request will create an order in Cosmos DB, and a Change Feed trigger will automatically process it.
Before any of that can happen, you need the right tools installed and a project structure ready to go. In this step, you will install Azure Functions Core Tools, verify Python, authenticate the Azure CLI, and scaffold your project.
In this step, get ready to:
- Install Azure Functions Core Tools v4 and Python 3.11+.
- Create the project directory structure and connect it to GitHub.
- Initialize the Azure Functions project with the Python v2 programming model.
Install and verify your tools
This project requires Azure Functions Core Tools v4 (the local runtime for Azure Functions) and Python 3.11+. Both are version-pinned, so you need to confirm you have the correct versions installed.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check your Azure Functions Core Tools version by running:
func --version
✔️ I see version 4.x or higher
Your Core Tools installation is ready. Move on to the Python check below.
ⓧ I see an older version (3.x or lower)
You have an outdated version of Core Tools. You need version 4.12 or later. Upgrade using the same method you originally installed with.
On macOS, upgrade via Homebrew by running these commands:
brew tap azure/functions
brew upgrade azure-functions-core-tools@4
On Windows, download and run the latest v4.x 64-bit MSI installer from the Azure Functions Core Tools installation page. Uninstall the old version from Add/Remove Programs first.
- After upgrading, run func --version again to confirm you see 4.x.
ⓧ Command not found
Core Tools is not installed yet. Install it now.
On macOS, install via Homebrew by running these commands:
brew tap azure/functions
brew install azure-functions-core-tools@4
On Windows, download and run the v4.x 64-bit MSI installer from the Azure Functions Core Tools installation page.
- After installing, close and reopen your terminal.
- Run func --version to confirm you see 4.x.
- Check your Python version by running:
python3 --version
✔️ I see version 3.11 or higher
Your Python installation is ready. Move on to authenticating the Azure CLI below.
ⓧ I see a version lower than 3.11
You have Python installed, but the project requires 3.11 or higher. On macOS, the system python3 often resolves to an older version even after you install a newer one.
On macOS, install Python 3.11 via Homebrew by running:
brew install python@3.11
- After installing, use python3.11 --version to verify. If python3 --version still shows the old version, use python3.11 explicitly throughout this project.
On Windows, download the Python 3.11 installer from python.org/downloads. During installation, check Add Python to PATH.
ⓧ Command not found
Python is not installed. Install Python 3.11 now.
On macOS, install via Homebrew by running:
brew install python@3.11
- After installing, run python3.11 --version to confirm you see 3.11.
On Windows, download the Python 3.11 installer from python.org/downloads. During installation, check Add Python to PATH.
- Authenticate with Azure by running:
az login
A browser window opens and asks you to sign in with your Azure account. After signing in, your terminal displays your subscription details.
Having trouble with az login?
Make sure the Azure CLI is installed. If you see a command-not-found error, install the Azure CLI from the official installation guide.
If the browser does not open, try running az login --use-device-code and follow the on-screen instructions.
Help me fix my Azure CLI login.
Create the project structure
Your project needs two directories: one for Terraform infrastructure code, and one for the Azure Functions application code.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder with its subdirectories by running these commands:
mkdir order-pipeline
cd order-pipeline
mkdir infra functions
- Confirm the directories exist by running:
ls
You should see functions and infra listed in the output.
- Initialize a Git repository by running:
git init
- Go to github.com in your browser.
- Click the + icon in the top right corner, then select New repository.
- Name the repository order-pipeline.
- Leave it as Public and do not initialize with a README.
- Click Create repository.
- Copy the remote URL that GitHub shows you, then connect your local repo by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/order-pipeline.git"]]
What does this do?
The git remote add origin command links your local Git repository to the GitHub remote. This allows you to push code to GitHub later, which is essential for the CI/CD pipeline you will build in a later step.
Git not found?
If you see a command-not-found error for git, install Git from git-scm.com. On macOS, running any git command for the first time prompts you to install Xcode Command Line Tools.
Help me set up Git on my machine.
Initialize the Azure Functions project
Azure Functions Core Tools can scaffold a new Functions project for you. You will use the Python v2 programming model, which uses decorators instead of separate binding configuration files.
- Move into the functions directory by running:
cd functions
- Initialize the Functions project by running:
func init --python --model V2
What does this command do?
The func init command scaffolds a new Azure Functions project. The --python flag sets the language runtime. The --model V2 flag uses the newer Python programming model where you define triggers and bindings with decorators in code, instead of a separate function.json file.
This generates several files: function_app.py (your main app file), requirements.txt (Python dependencies), host.json (runtime configuration), and local.settings.json (local environment settings).
- Open requirements.txt and confirm it contains azure-functions as a dependency. If it does not, add azure-functions on its own line and save the file.
- Verify the project starts correctly by running:
func start
You should see output indicating the Functions host has started, with a message that no functions are loaded. This confirms Core Tools can run your project.
- Press Ctrl+C to stop the local server.
Seeing an error when running func start?
If you see an error about a missing Python worker, make sure python3 (or python3.11) is available on your PATH. Core Tools needs to find a compatible Python interpreter.
If you see a storage-related error, confirm that local.settings.json has "AzureWebJobsStorage": "UseDevelopmentStorage=true" in its Values section.
Help me fix func start errors.
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in the functions/ directory.
ⓧ I'd like to double check the full code
Here is what your key files should look like at this point:
functions/requirements.txt
azure-functions
functions/local.settings.json
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "python"
}
}
Your environment is ready and your project structure is in place. Next up, you will write the Terraform configuration to provision all of the Azure infrastructure your pipeline needs.
Write the Terraform Infrastructure
Your development environment is set up and your project structure is ready. Now it's time to define the Azure infrastructure that will power your event-driven pipeline.
Instead of clicking through the Azure Portal, you will define every resource as code using Terraform. This means your entire infrastructure is version-controlled, repeatable, and reviewable in pull requests.
In this step, get ready to:
- Create the Terraform provider configuration for Azure.
- Define the core infrastructure resources (storage, compute, database).
- Set up RBAC role assignments for zero-secret security.
Create the provider configuration
Terraform needs to know which cloud provider you are targeting and which version of the provider plugin to use. The provider file tells Terraform to download and use the Azure Resource Manager plugin.
- Create a new file called providers.tf inside your infra/ directory by running this command:
touch infra/providers.tf
- Open infra/providers.tf in your code editor and paste the following configuration:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
required_version = ">= 1.5.0"
}
provider "azurerm" {
features {}
}
What does this configuration do?
The required_providers block pins the azurerm provider to version ~> 4.0. This means Terraform will accept any 4.x release but not 5.0.
The required_version constraint ensures anyone running this configuration has Terraform 1.5.0 or higher installed. The provider block with features {} activates the azurerm provider with its default behaviour.
- Save providers.tf.
- Initialize Terraform by running this command from inside the infra/ directory:
cd infra
terraform init
You should see output confirming the backend was initialized and the azurerm provider was installed. The final line reads Terraform has been successfully initialized!.
Seeing a provider error?
Make sure you saved providers.tf before running terraform init. Terraform reads the file from disk, not from your editor buffer.
If you see a network error, check that your internet connection is active. Terraform needs to download the provider plugin from the HashiCorp registry.
help me fix a terraform init error with the azurerm provider
Define the core infrastructure
Your pipeline needs a handful of Azure resources that work together. A Resource Group holds everything, a Storage Account backs the Functions runtime, a Service Plan defines the compute tier, and Cosmos DB stores your order documents.
- Create a new file called main.tf inside the infra/ directory:
touch main.tf
- Open infra/main.tf and paste the variables and foundational resources:
variable "location" {
default = "australiaeast"
}
variable "project_name" {
default = "orderpipeline"
}
resource "azurerm_resource_group" "rg" {
name = "rg-${var.project_name}"
location = var.location
}
resource "azurerm_storage_account" "storage" {
name = "${var.project_name}stor"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
account_tier = "Standard"
account_replication_type = "LRS"
}
resource "azurerm_service_plan" "plan" {
name = "${var.project_name}-plan"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
os_type = "Linux"
sku_name = "Y1"
}
What do these resources do?
The two variable blocks let you reuse the project name and region throughout the file without repeating strings.
The azurerm_resource_group is the container that holds all your Azure resources. The azurerm_storage_account provides blob storage that the Azure Functions runtime needs internally. The azurerm_service_plan with sku_name = "Y1" sets up the Consumption tier, meaning you only pay per execution.
You won't see a change yet. The next chunk adds the database layer.
- Below the Service Plan resource in main.tf, add the Cosmos DB resources:
resource "azurerm_cosmosdb_account" "cosmos" {
name = "${var.project_name}-cosmos"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
offer_type = "Standard"
kind = "GlobalDocumentDB"
free_tier_enabled = true
consistency_policy {
consistency_level = "Session"
}
geo_location {
location = azurerm_resource_group.rg.location
failover_priority = 0
}
}
resource "azurerm_cosmosdb_sql_database" "db" {
name = "OrdersDB"
resource_group_name = azurerm_resource_group.rg.name
account_name = azurerm_cosmosdb_account.cosmos.name
}
resource "azurerm_cosmosdb_sql_container" "orders" {
name = "orders"
resource_group_name = azurerm_resource_group.rg.name
account_name = azurerm_cosmosdb_account.cosmos.name
database_name = azurerm_cosmosdb_sql_database.db.name
partition_key_paths = ["/orderId"]
throughput = 400
}
What is Cosmos DB free tier?
Setting free_tier_enabled = true gives you 1000 RU/s of provisioned throughput and 25 GB of storage at no cost. One free tier account is allowed per Azure subscription.
The orders container uses /orderId as its partition key. This means Cosmos DB distributes documents across partitions based on each order's unique ID, keeping queries fast.
You won't see a change yet. The next chunk adds the Function App that ties everything together.
- Below the Cosmos DB container resource in main.tf, add the Linux Function App:
resource "azurerm_linux_function_app" "func" {
name = "${var.project_name}-func"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
storage_account_name = azurerm_storage_account.storage.name
storage_account_access_key = azurerm_storage_account.storage.primary_access_key
service_plan_id = azurerm_service_plan.plan.id
identity {
type = "SystemAssigned"
}
site_config {
application_stack {
python_version = "3.11"
}
}
app_settings = {
"COSMOS_CONNECTION__accountEndpoint" = azurerm_cosmosdb_account.cosmos.endpoint
}
}
What makes this Function App secure?
The identity block with type = "SystemAssigned" creates a managed identity for the Function App. This identity lets Azure authenticate the Function App to other services without any passwords or connection strings in your code.
The COSMOS_CONNECTION__accountEndpoint app setting tells the Azure Functions runtime to connect to Cosmos DB using identity-based authentication. The double underscore (__) is a special naming convention that Azure interprets as a nested configuration property.
- Save main.tf.
- Verify the syntax is valid by running:
terraform validate
You should see Success! The configuration is valid. in the output.
Seeing a validation error?
Check for missing closing braces. Each resource block in HCL needs a matching } for every {.
If you see an error about an undeclared resource, make sure you pasted the chunks in order. The Function App references the storage account and service plan defined above it.
help me fix a terraform validate error in my main.tf
✔️ Awesome, I've got everything!
Great. Make sure you have saved main.tf with all three sections (variables, Cosmos DB, Function App).
ⓧ I'd like to double check the full code
variable "location" {
default = "australiaeast"
}
variable "project_name" {
default = "orderpipeline"
}
resource "azurerm_resource_group" "rg" {
name = "rg-${var.project_name}"
location = var.location
}
resource "azurerm_storage_account" "storage" {
name = "${var.project_name}stor"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
account_tier = "Standard"
account_replication_type = "LRS"
}
resource "azurerm_service_plan" "plan" {
name = "${var.project_name}-plan"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
os_type = "Linux"
sku_name = "Y1"
}
resource "azurerm_cosmosdb_account" "cosmos" {
name = "${var.project_name}-cosmos"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
offer_type = "Standard"
kind = "GlobalDocumentDB"
free_tier_enabled = true
consistency_policy {
consistency_level = "Session"
}
geo_location {
location = azurerm_resource_group.rg.location
failover_priority = 0
}
}
resource "azurerm_cosmosdb_sql_database" "db" {
name = "OrdersDB"
resource_group_name = azurerm_resource_group.rg.name
account_name = azurerm_cosmosdb_account.cosmos.name
}
resource "azurerm_cosmosdb_sql_container" "orders" {
name = "orders"
resource_group_name = azurerm_resource_group.rg.name
account_name = azurerm_cosmosdb_account.cosmos.name
database_name = azurerm_cosmosdb_sql_database.db.name
partition_key_paths = ["/orderId"]
throughput = 400
}
resource "azurerm_linux_function_app" "func" {
name = "${var.project_name}-func"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
storage_account_name = azurerm_storage_account.storage.name
storage_account_access_key = azurerm_storage_account.storage.primary_access_key
service_plan_id = azurerm_service_plan.plan.id
identity {
type = "SystemAssigned"
}
site_config {
application_stack {
python_version = "3.11"
}
}
app_settings = {
"COSMOS_CONNECTION__accountEndpoint" = azurerm_cosmosdb_account.cosmos.endpoint
}
}
- Next, create a file called outputs.tf in the same infra/ directory. This file exports key values that other tools (like your CI/CD pipeline) will need:
output "function_app_name" {
value = azurerm_linux_function_app.func.name
}
output "function_app_url" {
value = "https://${azurerm_linux_function_app.func.default_hostname}"
}
output "cosmos_db_endpoint" {
value = azurerm_cosmosdb_account.cosmos.endpoint
}
output "resource_group_name" {
value = azurerm_resource_group.rg.name
}
Why define outputs?
Terraform outputs let you retrieve important values after a deployment completes. Your GitHub Actions pipeline will use function_app_name to know where to deploy the function code.
You can also run terraform output manually to grab the Function App URL for testing.
- Save outputs.tf.
Define the RBAC role assignments
Your Function App has a managed identity, but it doesn't yet have permission to read or write data in Cosmos DB. You need to assign it the correct RBAC role on the Cosmos DB data plane.
Why RBAC instead of connection strings?
Connection strings are long-lived secrets that can leak through logs, config files, or source control. RBAC with managed identity means there are zero secrets to rotate or protect.
Azure handles the credential lifecycle automatically. Your code never sees a password.
- Create a file called roles.tf in the infra/ directory and paste the role assignment:
resource "azurerm_cosmosdb_sql_role_assignment" "func_data_contributor" {
resource_group_name = azurerm_resource_group.rg.name
account_name = azurerm_cosmosdb_account.cosmos.name
role_definition_id = "${azurerm_cosmosdb_account.cosmos.id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002"
principal_id = azurerm_linux_function_app.func.identity[0].principal_id
scope = azurerm_cosmosdb_account.cosmos.id
}
What does this role assignment do?
The GUID 00000000-0000-0000-0000-000000000002 is the well-known ID for the "Cosmos DB Built-in Data Contributor" role. This role grants full read and write access to the data plane (documents and queries) but not the control plane (account settings).
The principal_id references the Function App's managed identity. Terraform resolves this automatically from the identity[0].principal_id export.
- Save roles.tf.
- Run a plan to confirm Terraform can parse all four files and build a valid execution graph:
terraform plan
You should see a list of resources Terraform plans to create, ending with a summary like Plan: 8 to add, 0 to change, 0 to destroy.
Terraform plan failed?
If you see an authentication error, make sure you ran az login earlier and that your session is still active. Azure CLI tokens expire after a period of inactivity.
If you see an error about a missing resource reference, check that main.tf, roles.tf, and outputs.tf are all saved inside the same infra/ directory. Terraform reads every .tf file in the working directory as one configuration.
help me fix a terraform plan error for my Azure order pipeline infrastructure
Your infrastructure is fully defined as code. Next up, you will write the Python functions that bring this pipeline to life.
Write the Azure Functions
Your infrastructure is defined in Terraform and ready to provision. Now you need the application code that actually handles orders.
In this step, you will write two Azure Functions that form the core of the event-driven pipeline. The first function accepts an HTTP POST request and writes an order to Cosmos DB. The second function reacts automatically to new orders via the Change Feed and processes them.
In this step, get ready to:
- Create an HTTP-triggered function that writes orders to Cosmos DB.
- Create a Change Feed-triggered function that processes new orders.
- Configure host and connection settings, then verify locally.
Create the HTTP-triggered order function
The first function is the entry point to your pipeline. It receives an HTTP POST request containing order details and writes the order document to Cosmos DB using an output binding.
- In your code editor, open the functions/ folder inside order-pipeline/.
- Create a new file called function_app.py inside functions/ and paste the following code:
import azure.functions as func
import logging
import json
from datetime import datetime, timezone
app = func.FunctionApp()
@app.route(route="orders", methods=["POST"], auth_level=func.AuthLevel.FUNCTION)
@app.cosmos_db_output(
arg_name="outputDocument",
database_name="OrdersDB",
container_name="orders",
connection="COSMOS_CONNECTION",
)
def create_order(req: func.HttpRequest, outputDocument: func.Out[func.Document]) -> func.HttpResponse:
logging.info("Processing create order request.")
# Parse the incoming JSON body
try:
order = req.get_json()
except ValueError:
return func.HttpResponse("Invalid JSON body", status_code=400)
# Validate required fields are present
required_fields = ["orderId", "customerName", "items", "totalAmount"]
for field in required_fields:
if field not in order:
return func.HttpResponse(f"Missing required field: {field}", status_code=400)
# Enrich the order with metadata
order["status"] = "pending"
order["createdAt"] = datetime.now(timezone.utc).isoformat()
order["id"] = order["orderId"]
# Write the order document to Cosmos DB
outputDocument.set(func.Document.from_dict(order))
return func.HttpResponse(
json.dumps(order),
status_code=201,
mimetype="application/json",
)
What does this code do?
The @app.route decorator registers this function as an HTTP endpoint at /api/orders accepting POST requests.
The @app.cosmos_db_output decorator tells Azure Functions to automatically write whatever you set on outputDocument to the OrdersDB/orders container. The connection="COSMOS_CONNECTION" parameter references the app setting prefix that resolves to your Cosmos DB endpoint using managed identity.
The function validates the incoming JSON, enriches it with a pending status and timestamp, then hands it to the output binding. No connection string code needed.
- Save function_app.py.
- You won't see a change yet. The next chunk adds the second function, then you will configure the runtime settings to test both together.
Add the Change Feed-triggered processing function
The second function is what makes this pipeline event-driven. Instead of polling for new orders, the Cosmos DB Change Feed pushes new documents to your function automatically.
- In function_app.py, add the following code below the create_order function:
@app.cosmos_db_trigger(
arg_name="documents",
database_name="OrdersDB",
container_name="orders",
connection="COSMOS_CONNECTION",
lease_container_name="leases",
create_lease_container_if_not_exists=True,
)
def process_order(documents: func.DocumentList) -> None:
logging.info(f"Change Feed triggered with {len(documents)} document(s).")
# Process each new or modified document
for doc in documents:
order = doc.to_dict()
if order.get("status") == "pending":
order["status"] = "processed"
order["processedAt"] = datetime.now(timezone.utc).isoformat()
logging.info(f"Processed order {order.get('orderId')} for customer {order.get('customerName')}.")
What does this code do?
The @app.cosmos_db_trigger decorator subscribes this function to the Change Feed on the orders container. Every time a document is inserted or updated, Azure invokes this function with the changed documents.
The lease_container_name="leases" parameter tells Azure where to track which changes have already been processed. Setting create_lease_container_if_not_exists=True lets the runtime create this container automatically.
The function checks if the order status is pending, then enriches it with a processed status and a processedAt timestamp. This avoids reprocessing orders that have already been handled.
Why does the Change Feed trigger not cause an infinite loop?
The process_order function only logs the processing result. It does not write back to the same container.
If it did write back, that write would trigger the Change Feed again, creating an infinite loop. In a production system, you would write enriched orders to a separate container or use a status flag to break the cycle.
- Save function_app.py.
- You won't see a change yet. The next substep configures the runtime files needed to start the function host.
✔️ Awesome, I've got everything!
Great. Double check you have saved function_app.py with both functions in it.
ⓧ I'd like to double check the full code
import azure.functions as func
import logging
import json
from datetime import datetime, timezone
app = func.FunctionApp()
@app.route(route="orders", methods=["POST"], auth_level=func.AuthLevel.FUNCTION)
@app.cosmos_db_output(
arg_name="outputDocument",
database_name="OrdersDB",
container_name="orders",
connection="COSMOS_CONNECTION",
)
def create_order(req: func.HttpRequest, outputDocument: func.Out[func.Document]) -> func.HttpResponse:
logging.info("Processing create order request.")
try:
order = req.get_json()
except ValueError:
return func.HttpResponse("Invalid JSON body", status_code=400)
required_fields = ["orderId", "customerName", "items", "totalAmount"]
for field in required_fields:
if field not in order:
return func.HttpResponse(f"Missing required field: {field}", status_code=400)
order["status"] = "pending"
order["createdAt"] = datetime.now(timezone.utc).isoformat()
order["id"] = order["orderId"]
outputDocument.set(func.Document.from_dict(order))
return func.HttpResponse(
json.dumps(order),
status_code=201,
mimetype="application/json",
)
@app.cosmos_db_trigger(
arg_name="documents",
database_name="OrdersDB",
container_name="orders",
connection="COSMOS_CONNECTION",
lease_container_name="leases",
create_lease_container_if_not_exists=True,
)
def process_order(documents: func.DocumentList) -> None:
logging.info(f"Change Feed triggered with {len(documents)} document(s).")
for doc in documents:
order = doc.to_dict()
if order.get("status") == "pending":
order["status"] = "processed"
order["processedAt"] = datetime.now(timezone.utc).isoformat()
logging.info(f"Processed order {order.get('orderId')} for customer {order.get('customerName')}.")
Configure the connection settings and test locally
Azure Functions needs two configuration files to know which extensions to load and how to connect to services. You will create host.json for the runtime configuration and update local.settings.json with the Cosmos DB connection.
- Create a new file called host.json inside the functions/ folder and paste the following:
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
}
}
},
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}
What does host.json do?
The extensionBundle section tells the Functions runtime to automatically download the Cosmos DB binding extensions at startup. The version range [4.*, 5.0.0) means any bundle in the 4.x series.
Without this file, the runtime would not know how to handle the cosmos_db_output and cosmos_db_trigger decorators in your function code.
- Save host.json.
Now update the local settings to point at your Cosmos DB endpoint.
- Open functions/local.settings.json in your editor.
- Find this line:
"FUNCTIONS_WORKER_RUNTIME": "python"
- Add a comma after "python" and add the Cosmos DB endpoint setting on the next line, so the Values block looks like this:
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "python",
"COSMOS_CONNECTION__accountEndpoint": "your-cosmos-db-endpoint-here"
}
What is COSMOS_CONNECTION__accountEndpoint?
The double-underscore pattern (COSMOS_CONNECTION__accountEndpoint) tells the Functions runtime to use identity-based authentication. It resolves the COSMOS_CONNECTION reference in your decorators to just an account endpoint, not a full connection string with secrets.
When deployed, the Function App's managed identity handles authentication automatically. For local development, you will replace the placeholder with your real Cosmos DB endpoint after deploying the infrastructure in the next step.
- Save local.settings.json.
Now verify that the Functions runtime recognizes both functions.
- In your terminal, make sure you are inside the functions/ directory and start the local host by running:
func start
You should see output listing both registered functions, including a line like:
Http Functions:
create_order: [POST] http://localhost:7071/api/orders
What should I see?
The func start output shows all functions discovered in function_app.py. You should see create_order listed as an HTTP function and process_order listed as a Cosmos DB triggered function.
The HTTP endpoint will not be able to actually write to Cosmos DB yet because the infrastructure has not been deployed. That happens in the next step. For now, seeing both functions registered confirms your code and configuration are correct.
- Press Ctrl+C to stop the local host.
Not seeing your functions listed?
- Make sure host.json exists in the same directory as function_app.py. The runtime needs it to load extension bundles.
- Check for syntax errors in function_app.py. A missing comma or mismatched parenthesis will prevent the runtime from loading any functions.
- Confirm you are running func start from inside the functions/ directory, not the parent order-pipeline/ directory.
Why aren't my Azure Functions showing up when I run func start?
Your function code is ready. Next up, you will deploy the Terraform infrastructure and publish these functions to Azure for a live end-to-end test.
Deploy Infrastructure and Functions
You have a complete Terraform configuration and two tested Azure Functions on your local machine. Now it's time to bring everything to life in Azure.
In this step, you will deploy the infrastructure with Terraform, publish your function code to the live Function App, and run an end-to-end test to confirm the event-driven pipeline works in the cloud.
In this step, get ready to:
- Deploy all Azure infrastructure with Terraform.
- Publish your function code to the live Function App.
- Test the full pipeline end-to-end in the cloud.
Deploy the Terraform infrastructure
Your Terraform plan validated successfully in the previous step. Now you will apply it to provision real resources in your Azure subscription.
- Navigate to your infra/ directory and apply the Terraform configuration by running these commands:
cd ~/Desktop/order-pipeline/infra
terraform init
terraform apply
What do these commands do?
- terraform init ensures all provider plugins are downloaded and the working directory is ready. You ran this before, but it is safe and idempotent to run again.
- terraform apply reads your .tf files, calculates what needs to be created, and prompts you to confirm before provisioning any resources.
- Terraform will display a plan showing all the resources it will create. Type yes and press Enter to confirm.
How long does this take?
The Cosmos DB account is the slowest resource to provision. Expect terraform apply to take 5-10 minutes total.
- Once the apply completes, Terraform prints four outputs. Note these values:
terraform output
You should see function_app_name, function_app_url, cosmos_db_endpoint, and resource_group_name printed to your terminal.
Seeing an error during apply?
- If you see a free tier conflict, it means your subscription already has a free-tier Cosmos DB account. Set free_tier_enabled = false in main.tf and re-run terraform apply.
- If you see a permission error, confirm you are logged in with az login and that your account has Contributor access on the subscription.
- help me troubleshoot my Terraform apply error for this Azure project
Deploy the function code
The infrastructure is live, but the Function App is empty. You need to publish your Python functions to it.
- Navigate to the functions/ directory and publish your code by running these commands:
cd ~/Desktop/order-pipeline/functions
func azure functionapp publish orderpipeline-func
What does this command do?
The func azure functionapp publish command packages your project files into a zip and deploys them to the specified Function App in Azure. It uses your active Azure CLI session for authentication.
- Wait for the deployment to finish. You should see output confirming that create_order and process_order are deployed.
- Verify the deployment in the Azure Portal. Open portal.azure.com in your browser.
- Search for orderpipeline-func in the top search bar and select the Function App.
- In the left menu, expand Functions and click Functions.
- Confirm that both create_order and process_order appear in the list.
Functions not showing up?
- Newly deployed functions can take a minute or two to appear. Wait and refresh the page.
- If the publish command failed, check that you ran it from inside the functions/ directory (the folder containing function_app.py).
- help me debug why my Azure Functions deployment is not showing functions in the portal
Test the live pipeline end-to-end
Both functions are deployed. Now you will send a real HTTP request to the live endpoint and confirm that the entire event-driven pipeline fires automatically.
- First, retrieve your function's URL including the access key by running:
func azure functionapp list-functions orderpipeline-func --show-keys
Why do you need an access key?
Your create_order function uses auth_level=func.AuthLevel.FUNCTION, which means every request must include a valid function key. The --show-keys flag displays the full URL with the key appended as a code query parameter.
- Copy the Invoke URL for create_order from the output. It looks like https://orderpipeline-func.azurewebsites.net/api/orders?code=....
- Send a test order to the live endpoint by running this curl command (replace the URL with your copied value):
curl -X POST "your-full-function-url-with-code-here" \
-H "Content-Type: application/json" \
-d '{"orderId": "order-001", "customerName": "Alice Smith", "items": ["Widget A", "Widget B"], "totalAmount": 59.99}'
You should receive a JSON response with your order data and "status": "pending".
What happens behind the scenes?
The create_order function writes the order document to Cosmos DB with status: "pending". The Cosmos DB Change Feed detects this new document and automatically triggers process_order.
The process_order function then updates the document's status to "processed" and adds a processedAt timestamp. This all happens within a few seconds.
- Wait about 10-15 seconds for the Change Feed to trigger, then verify the result in the Azure Portal.
- In the portal, search for orderpipeline-cosmos and open the Cosmos DB account.
- In the left menu, click Data Explorer.
- Expand OrdersDB, then orders, then click Items.
- Click on the order-001 document to view it.
- Confirm that the document has "status": "processed" and a processedAt timestamp.
Order status still shows pending?
- The Change Feed can take up to 30 seconds to fire on a new Function App. Wait and refresh Data Explorer.
- If status stays "pending" after a minute, check the process_order function's logs in the portal (Function App, then process_order, then Monitor) for errors.
- Verify that the RBAC role assignment deployed correctly. The managed identity needs the Cosmos DB Built-in Data Contributor role to read and write data.
- help me troubleshoot why my Cosmos DB Change Feed trigger is not firing
Your event-driven pipeline is live in Azure. An HTTP request creates an order, Cosmos DB's Change Feed detects it, and a second function processes it automatically. Next up, you will automate this entire deployment with a GitHub Actions CI/CD pipeline.
Add the GitHub Actions CI/CD Pipeline
Your event-driven pipeline is live and processing orders end-to-end. But right now, every code change requires a manual terraform apply and func azure functionapp publish to reach production.
In this step, you will create a GitHub Actions workflow that automates the entire deployment. Every push to main will provision infrastructure with Terraform and deploy your function code automatically.
In this step, get ready to:
- Create a GitHub Actions workflow that deploys infrastructure and function code on push.
- Configure OIDC authentication between GitHub and Azure for secret-free deployments.
- Trigger a successful automated deployment by pushing to main.
Create the workflow file
GitHub Actions workflows live in the .github/workflows/ directory of your repository. This file defines a CI/CD pipeline that triggers on every push to main, logs into Azure using OIDC, and runs Terraform to provision your infrastructure.
- Create the directory structure for the workflow by running these commands from the root of your order-pipeline folder:
mkdir -p .github/workflows
- Create the file .github/workflows/deploy.yml and paste the following code:
name: Deploy Order Pipeline
on:
push:
branches:
- main
permissions:
id-token: write
contents: read
env:
TERRAFORM_DIR: infra
FUNCTIONS_DIR: functions
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false
- name: Terraform Init
working-directory: ${{ env.TERRAFORM_DIR }}
run: terraform init
- name: Terraform Apply
working-directory: ${{ env.TERRAFORM_DIR }}
run: terraform apply -auto-approve
- name: Get Function App Name
id: get-name
working-directory: ${{ env.TERRAFORM_DIR }}
run: echo "FUNC_APP_NAME=$(terraform output -raw function_app_name)" >> $GITHUB_OUTPUT
What does this workflow do so far?
- permissions: id-token: write allows the runner to request an OIDC token from GitHub, which Azure trusts instead of a stored secret.
- azure/login@v2 exchanges the GitHub OIDC token for an Azure access token using your app registration's federated credential.
- hashicorp/setup-terraform@v4 installs Terraform on the runner. Setting terraform_wrapper: false prevents output wrapping that breaks subsequent steps.
- The final step reads the function_app_name Terraform output and stores it in GITHUB_OUTPUT so the function deployment steps can reference it.
- Save deploy.yml.
Why OIDC instead of a service principal secret?
OIDC (OpenID Connect) federated credentials let GitHub Actions authenticate to Azure without storing any long-lived secrets. GitHub issues a short-lived token for each workflow run, and Azure validates it against your app registration's trust policy.
This means no secret rotation, no risk of leaked credentials, and a cleaner audit trail.
Add the function deployment steps
After Terraform provisions the infrastructure, the workflow needs to package and deploy your Python function code to the Function App.
- In .github/workflows/deploy.yml, add the following steps at the end of the steps: list, below the Get Function App Name step:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Dependencies
working-directory: ${{ env.FUNCTIONS_DIR }}
run: pip install -r requirements.txt --target=".python_packages/lib/site-packages"
- name: Deploy Function App
uses: Azure/functions-action@v1
with:
app-name: ${{ steps.get-name.outputs.FUNC_APP_NAME }}
package: ${{ env.FUNCTIONS_DIR }}
What do these steps do?
- actions/setup-python@v5 installs Python 3.11 on the runner so dependencies can be installed.
- The --target flag installs packages into the .python_packages folder, which is where Azure Functions expects bundled dependencies.
- Azure/functions-action@v1 packages and deploys the functions/ directory to the Function App whose name was extracted from Terraform output.
- Save deploy.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved .github/workflows/deploy.yml with all the steps from both chunks above.
ⓧ I'd like to double check the full code
Here is the complete deploy.yml file:
name: Deploy Order Pipeline
on:
push:
branches:
- main
permissions:
id-token: write
contents: read
env:
TERRAFORM_DIR: infra
FUNCTIONS_DIR: functions
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Azure Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false
- name: Terraform Init
working-directory: ${{ env.TERRAFORM_DIR }}
run: terraform init
- name: Terraform Apply
working-directory: ${{ env.TERRAFORM_DIR }}
run: terraform apply -auto-approve
- name: Get Function App Name
id: get-name
working-directory: ${{ env.TERRAFORM_DIR }}
run: echo "FUNC_APP_NAME=$(terraform output -raw function_app_name)" >> $GITHUB_OUTPUT
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Dependencies
working-directory: ${{ env.FUNCTIONS_DIR }}
run: pip install -r requirements.txt --target=".python_packages/lib/site-packages"
- name: Deploy Function App
uses: Azure/functions-action@v1
with:
app-name: ${{ steps.get-name.outputs.FUNC_APP_NAME }}
package: ${{ env.FUNCTIONS_DIR }}
YAML indentation errors?
YAML is whitespace-sensitive. Each nested level uses exactly 2 spaces. Make sure steps: items are indented with 6 spaces (3 levels deep under jobs > deploy > steps).
If your editor auto-inserts tabs instead of spaces, check its settings. Tabs in YAML cause parse failures. Help me fix YAML indentation in my deploy.yml
Configure GitHub repository secrets
The workflow references three secrets for OIDC authentication. Before these work, you need to create a Microsoft Entra app registration with a federated credential that trusts your GitHub repository.
- Create a new app registration by running this command:
az ad app create --display-name "order-pipeline-github" --query appId -o tsv
- Copy the output value. This is your Application (client) ID and will become the AZURE_CLIENT_ID secret.
- Create a service principal for the app by running this command (replace your-client-id-here with the value you just copied):
az ad sp create --id your-client-id-here
- Get your Tenant ID and Subscription ID by running:
az account show --query "{tenantId: tenantId, subscriptionId: id}" -o table
- Note the tenantId (this is AZURE_TENANT_ID) and id (this is AZURE_SUBSCRIPTION_ID).
- Assign the Contributor role to the service principal scoped to your resource group (replace your-client-id-here with your Application ID):
az role assignment create --assignee your-client-id-here --role Contributor --scope /subscriptions/$(az account show --query id -o tsv)/resourceGroups/rg-orderpipeline
- Create a federated identity credential so GitHub Actions can authenticate as this app. Create a file called credential.json with the following content (replace your-org/your-repo with your actual GitHub username and repository name):
{
"name": "order-pipeline-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:your-org/your-repo:ref:refs/heads/main",
"description": "GitHub Actions deploy from main branch",
"audiences": [
"api://AzureADTokenExchange"
]
}
- Create the federated credential by running (replace your-client-id-here with your Application ID):
az ad app federated-credential create --id your-client-id-here --parameters credential.json
What is a federated credential?
The subject field tells Azure to only accept tokens from workflows running on the main branch of your specific repository. The issuer is GitHub's OIDC token endpoint.
Together, these fields create a zero-secret trust relationship. No API keys or passwords are stored anywhere.
- Now add the three secrets to your GitHub repository. Open your repository on GitHub.
- Click the Settings tab at the top of the repository page.
- In the left sidebar, click Secrets and variables, then select Actions.
- Click New repository secret.
- Add the following three secrets one at a time:
- Name: AZURE_CLIENT_ID with the Application (client) ID from earlier.
- Name: AZURE_TENANT_ID with the Tenant ID from earlier.
- Name: AZURE_SUBSCRIPTION_ID with the Subscription ID from earlier.
- Click Add secret after entering each one.
Delete credential.json after use
The credential.json file is only needed during setup. You can delete it now or add it to your .gitignore so it does not get committed.
- Commit and push all your changes to the main branch by running:
git add .
git commit -m "Add CI/CD workflow"
git push origin main
- Open your GitHub repository in the browser.
- Click the Actions tab at the top.
- You should see a workflow run named Deploy Order Pipeline triggered by your push. Click on it to watch the steps execute.
The workflow should complete with a green checkmark. Each step (Azure Login, Terraform Init, Terraform Apply, Deploy Function App) should show as successful.
Workflow failing at Azure Login?
This is almost always a mismatch between the federated credential's subject and your actual repository path or branch name.
- Double check that the subject in your credential.json exactly matches repo:YOUR-USERNAME/YOUR-REPO:ref:refs/heads/main with your actual GitHub username and repo name (case-sensitive).
- Verify that the AZURE_CLIENT_ID secret matches the appId from az ad app create, not the object ID.
- Ensure permissions: id-token: write is set at the top level of your workflow file.
My GitHub Actions workflow is failing at the Azure Login step with an OIDC error. Help me debug it.
Secret mission
Add a GET Endpoint to Query Processed Orders
Your pipeline can create and process orders, but how do you read them back? Add an HTTP GET endpoint that uses a Cosmos DB input binding to query all processed orders and return them as JSON.
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's free tier for Cosmos DB and the Functions Consumption plan, so costs stay at $0 for light usage. The Storage Account may incur a few pennies over time.
Cost warning
The Azure Storage Account (orderpipelinestor) can accumulate minimal charges (a few cents per month) even when idle. If you are not planning to return to this project, use the Delete tab below to remove everything.
Resources you used:
- Azure Resource Group (rg-orderpipeline).
- Azure Storage Account (orderpipelinestor).
- Azure Cosmos DB Account (orderpipeline-cosmos) with database OrdersDB and container orders.
- Azure Function App (orderpipeline-func).
- Azure Service Plan (orderpipeline-plan).
- GitHub repository secrets (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID).
- Local project folder (order-pipeline/).
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend this project soon.
- Your Azure resources remain deployed. Cosmos DB stays within the free tier (1000 RU/s, 25 GB) for light usage.
- The Functions Consumption plan has no idle cost. You are only charged per execution (1 million executions per month free).
- The Storage Account may cost a few cents per month for stored data.
- Your GitHub repository secrets remain configured for future deployments.
Pause - I'll come back to this later
Serverless resources on a Consumption plan have no idle charges. There is nothing to pause.
- Your Function App only costs money when it receives requests. If nobody calls it, it costs nothing.
- Cosmos DB free tier continues at no charge as long as you stay within 1000 RU/s and 25 GB.
- The only potential cost is the Storage Account, which accumulates a few cents per month.
- If you want to avoid even that, use the Delete tab and re-deploy with terraform apply when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes everything from Azure and your local machine.
- Destroy all Azure resources by running these commands from your project folder:
cd order-pipeline/infra
terraform destroy
Terraform will show you a plan of all resources it will delete. Type yes when prompted to confirm.
- Delete your GitHub repository secrets. Go to your repository on GitHub, click Settings, then select Secrets and variables in the left sidebar, then click Actions. Click the delete icon next to each secret (AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID).
- Delete the local project folder by running:
cd ~/Desktop
rm -rf order-pipeline
On Windows, use this command instead:
cd ~\Desktop
rmdir /s /q order-pipeline
Nice Work!
Nice Work!
Nice work! You just built a fully automated, event-driven order processing pipeline on Azure from scratch.
You've learned how to:
- Build an event-driven serverless pipeline with Azure Functions and Cosmos DB Change Feed that automatically processes new orders.
- Provision all infrastructure with Terraform and secure it with managed identity and RBAC so there are zero secrets in your code.
- Automate the entire deployment with a GitHub Actions CI/CD pipeline that provisions infrastructure and deploys function code on every push to main.
- (Secret Mission) Add a GET query endpoint using a Cosmos DB input binding to fetch all processed orders.
Ready to quiz yourself?