Jon Gallant

azd Hooks in Python, JavaScript, and TypeScript

8 min read

I’ve wanted this for a long time. Since we first shipped hooks in azd, they’ve only supported two languages: Bash and PowerShell. That covered a lot of ground, but it also meant that if you wanted to run a Python script or a Node.js script as part of your deployment, you had to wrap it in a shell script. And that shell script would handle setting up the venv, installing dependencies, then calling your actual code. It worked, but it was clunky.

So I put together a template that shows off the new multi-language hook support in azd 1.23.15. Hooks now support Python, JavaScript, and TypeScript natively. Just point to the file. azd handles the rest.

What are azd hooks?

Quick refresher if you’re new to this. Hooks are scripts that run before or after azd commands. They let you plug custom logic into the deployment lifecycle - things like validating config before provisioning, generating reports after deployment, running health checks, seeding databases, whatever you need.

You define them in azure.yaml:

hooks:
preprovision:
shell: sh
run: ./scripts/validate.sh
postprovision:
shell: pwsh
run: ./scripts/report.ps1

The problem? Those two options - sh and pwsh - were it. That’s all you had.

The old way: shell wrappers everywhere

Let’s say you had a Python validation script. Here’s what you’d do before 1.23.15:

hooks:
preprovision:
shell: sh
run: |
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python hooks/validate.py

Or on Windows with PowerShell:

hooks:
preprovision:
shell: pwsh
run: |
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python hooks\validate.py

And then you’d need platform-specific overrides:

hooks:
preprovision:
posix:
shell: sh
run: |
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/python hooks/validate.py
windows:
shell: pwsh
run: |
python -m venv .venv
.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python hooks\validate.py

That’s a lot of boilerplate for “run my Python script.” Same story for JavaScript and TypeScript - you’d wrap node or npx tsx calls in shell scripts. Cross-platform? Now you’re maintaining two sets of wrapper scripts.

Here’s a side-by-side of before and after:

Terminal showing the old way with shell wrappers vs the new way with just a file path

The new way: just point to the file

Here’s the same thing with azd 1.23.15:

hooks:
preprovision:
run: hooks/preprovision/validate.py

That’s it. azd sees the .py extension from the path, finds requirements.txt in the same directory, creates a virtual environment, installs the dependencies, and runs your script. No shell wrapper. No platform overrides. It just works.

Same for JavaScript:

hooks:
postprovision:
run: hooks/postprovision/report.js

azd detects .js, finds package.json, runs npm install, then node report.js.

And TypeScript:

hooks:
postup:
run: hooks/postup/healthcheck.ts

azd detects .ts and runs it with npx tsx - no compile step needed. If there’s a package.json, dependencies get installed first.

Why you’d want this

Here’s why this matters:

Your team already knows Python/JS/TS. Not everyone is comfortable writing Bash. I was working with a team recently where their entire stack was TypeScript. They wanted to add a postprovision hook to validate their deployment and had to write it in PowerShell because that’s all azd supported. Now they can write it in the language they already know.

Better tooling. Python and TypeScript have type checkers, linters, debuggers, and IDEs that understand them well. Try debugging a complex Bash script vs. a Python script - it’s not close.

Real dependency management. Need to call an API? Parse JSON? Validate schemas? In Bash, that’s curl | jq pipelines that break in weird ways. In Python, it’s import requests. In TypeScript, you get type safety on top of that.

Cross-platform by default. Python, Node.js, and TypeScript run the same on Windows, macOS, and Linux. No more maintaining separate posix: and windows: hook blocks.

Python hooks in action

Here’s what the preprovision validation hook looks like from the template. It checks environment naming conventions before azd provisions anything:

#!/usr/bin/env python3
"""Pre-provision validation - checks environment config before deploying."""
import os
import sys
from colorama import Fore, Style, init
init()
def validate():
# Reads from azd-exported environment variables
env_name = os.environ.get("AZURE_ENV_NAME", "")
location = os.environ.get("AZURE_LOCATION", "")
banner = "=== PREPROVISION HOOK (Python) ==="
print(f"\n{Fore.CYAN}{Style.BRIGHT}{banner}{Style.RESET_ALL}")
print(f"{Fore.CYAN}Running pre-provision validation...{Style.RESET_ALL}\n")
errors = []
# Check required values are set
if not env_name:
errors.append("AZURE_ENV_NAME is not set.")
if not location:
errors.append("AZURE_LOCATION is not set.")
# Validate env_name format (only if set)
if env_name:
if len(env_name) > 20:
errors.append(
f"Environment name '{env_name}' is too long (max 20 chars)."
)
if not env_name.replace("-", "").isalnum():
errors.append(
f"Environment name '{env_name}' has invalid characters. "
"Use letters, numbers, and hyphens only."
)
if env_name.startswith("-") or env_name.endswith("-"):
errors.append("Environment name can't start or end with a hyphen.")
# Print results
if errors:
for err in errors:
print(f" {Fore.RED}x {err}{Style.RESET_ALL}")
print(
f"\n{Fore.RED}Validation failed with "
f"{len(errors)} error(s).{Style.RESET_ALL}"
)
sys.exit(1)
print(f" {Fore.GREEN}+ Environment name '{env_name}' is valid{Style.RESET_ALL}")
print(f" {Fore.GREEN}+ Location '{location}' is set{Style.RESET_ALL}")
print(f"\n{Fore.GREEN}All checks passed.{Style.RESET_ALL}")
print(f"{Fore.CYAN}{Style.BRIGHT}=== PREPROVISION HOOK COMPLETE ==={Style.RESET_ALL}\n")
if __name__ == "__main__":
validate()

The requirements.txt in the same directory just has colorama>=0.4.6. When azd runs this hook, it:

  1. Creates a Python virtual environment
  2. Installs colorama from requirements.txt
  3. Runs validate.py

All automatic. Here’s what it looks like:

Python preprovision hook validating environment name and location with green checkmarks

And if validation fails, the hook exits with a non-zero code and azd stops before provisioning anything:

Python validation hook failing with red error messages for invalid environment name

JavaScript hooks in action

The postprovision hook reads deployment outputs and generates a summary report:

// Post-provision hook - generates a deployment summary report.
import { writeFileSync, mkdirSync } from "node:fs";
import path from "node:path";
import chalk from "chalk";
// Reads from azd-exported environment variables
function getEnvValue(key) {
return process.env[key] ?? null;
}
function generateReport() {
const banner = "=== POSTPROVISION HOOK (JavaScript) ===";
console.log(`\n${chalk.cyan.bold(banner)}`);
console.log(chalk.cyan("Generating deployment report...\n"));
const envName = getEnvValue("AZURE_ENV_NAME");
const subscription = getEnvValue("AZURE_SUBSCRIPTION_ID");
const location = getEnvValue("AZURE_LOCATION");
const resourceGroup = getEnvValue("AZURE_RESOURCE_GROUP");
const report = {
environment: envName,
subscription,
location,
resourceGroup,
timestamp: new Date().toISOString(),
};
console.log(chalk.white(" Deployment Summary:"));
console.log(chalk.green(` + Environment: ${envName}`));
console.log(chalk.green(` + Location: ${location}`));
console.log(chalk.green(` + Resource Group: ${resourceGroup}`));
// Write report to .azure/reports/
mkdirSync(".azure/reports", { recursive: true });
const safeName = envName ? path.basename(envName) : "unknown";
const reportPath = `.azure/reports/deploy-${safeName}.json`;
writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(chalk.cyan(`\n Report saved to ${reportPath}`));
console.log(`${chalk.cyan.bold("=== POSTPROVISION HOOK COMPLETE ===")}\n`);
}
generateReport();

azd sees package.json with chalk as a dependency, runs npm install, then runs the script with node. Here’s the output:

JavaScript postprovision hook showing deployment summary with environment, location, and resource group

TypeScript hooks in action

This is the one I’m most excited about. TypeScript hooks give you type safety for your deployment automation. The postup hook runs a health check with typed interfaces:

// Post-up hook - type-safe health check for deployed resources.
import { execFileSync } from "node:child_process";
import chalk from "chalk";
interface DeploymentConfig {
envName: string;
location: string;
resourceGroup: string;
subscription: string;
}
interface CheckResult {
check: string;
status: "pass" | "fail" | "skip";
message: string;
}
// Reads from azd-exported environment variables
function getEnvValue(key: string): string | null {
const value = process.env[key];
return value ? value.trim() : null;
}
function loadConfig(): DeploymentConfig {
return {
envName: getEnvValue("AZURE_ENV_NAME") ?? "",
location: getEnvValue("AZURE_LOCATION") ?? "",
resourceGroup: getEnvValue("AZURE_RESOURCE_GROUP") ?? "",
subscription: getEnvValue("AZURE_SUBSCRIPTION_ID") ?? "",
};
}
function runChecks(config: DeploymentConfig): CheckResult[] {
const results: CheckResult[] = [];
const required: (keyof DeploymentConfig)[] = [
"envName", "location", "resourceGroup", "subscription",
];
for (const key of required) {
results.push({
check: `config.${key}`,
status: config[key] ? "pass" : "fail",
message: config[key]
? `Set to '${config[key]}'`
: "Missing required value",
});
}
// Check resource group exists in Azure
if (config.resourceGroup && config.subscription) {
try {
execFileSync(
"az",
["group", "show", "-n", config.resourceGroup,
"--subscription", config.subscription],
{ encoding: "utf-8", stdio: "pipe" },
);
results.push({
check: "resource-group-exists",
status: "pass",
message: `Resource group '${config.resourceGroup}' exists`,
});
} catch {
results.push({
check: "resource-group-exists",
status: "fail",
message: `Resource group '${config.resourceGroup}' not found`,
});
}
}
return results;
}

azd runs this with npx tsx - no tsc compile step, no tsconfig.json required. You write TypeScript, it runs TypeScript. Here’s the output:

TypeScript postup hook running health checks with typed results showing pass/fail status

The azure.yaml

Here’s the full azure.yaml from the template. Three hooks, three languages, zero shell wrappers:

name: azd-hooks-languages
hooks:
# Python - auto venv + requirements.txt
preprovision:
run: hooks/preprovision/validate.py
# JavaScript - auto npm install from package.json
postprovision:
run: hooks/postprovision/report.js
# TypeScript - runs via npx tsx, no compile step
postup:
run: hooks/postup/healthcheck.ts
infra:
provider: bicep
path: infra

Each hook directory has its own dependency file (requirements.txt or package.json), so dependencies stay isolated per hook.

Try it out

Update to azd 1.23.15 or later, then give it a shot:

Terminal window
azd update
azd init -t jongio/azd-hooks-languages
azd up

You’ll see all three hooks fire in sequence - Python validation before provisioning, JavaScript report after provisioning, and TypeScript health check at the end.

If you hit any issues, please file them on the azd repo. This is a new feature and the more real-world usage it gets, the better we can make it. I should have pushed for this sooner - it’s one of those things that seems obvious in hindsight. Big thanks to wbreza for building it out.

Share:
Share on X