Skip to main content

Azure Deployment

This guide walks through deploying a Catalyst Enterprise Self-Hosted region into a private Azure Virtual Network, with all traffic routed through a centralized Azure Firewall. It also shows how to configure the deployment with the optional PostgreSQL backing store required for Workflow visualization.

Demonstration only

This guide is intended as a reference walkthrough for demonstration and proof-of-concept deployments. It is not a production-ready deployment recipe — use the Production Planning guidance and adapt this script to your organization's networking, security, and operational requirements before going to production.

Prerequisites

Required tools

  • Azure CLI (v2.45.0+) — for managing Azure resources and authentication
  • Diagrid CLI (v0.12.0+) — for managing Catalyst regions and projects
  • Helm (v3.12.0+) — for deploying Catalyst to Kubernetes
  • kubectl (v1.28+) — for interacting with the Kubernetes cluster
  • jq (v1.6+) — for JSON parsing in shell scripts
note

The shell snippets in this guide assume a bash-compatible shell. On Windows, run them from WSL or Git Bash rather than PowerShell or Command Prompt.

Azure requirements

  • A valid Azure subscription with owner permissions for resource creation
  • Sufficient vCPU and memory quotas for AKS and VM resources
  • An SSH key pair for VM access (generate with ssh-keygen -t rsa -b 4096 if needed)

Scripts

The provisioning scripts referenced in this guide live in the guides/azure directory of the diagridio/charts repository. Clone the repository and run the scripts from inside that directory:

git clone https://github.com/diagridio/charts.git
cd charts/guides/azure

Architecture overview

The provisioning script creates a secure baseline environment for Catalyst Enterprise Self-Hosted on Azure, modeled on the AKS Baseline Architecture. All compute resources (AKS and the management VM) live in a private network, with all ingress and egress traffic controlled and inspected by Azure Firewall. Only required ports and FQDNs are allowed, and SSH access is tightly controlled via DNAT rules.

The script provisions:

  • Resource Group — logical container for all Catalyst-related resources

  • Virtual Network (VNet) — isolated network space (default 10.42.0.0/16)

  • Subnets

    • Primary subnet for AKS and the management VM (default 10.42.1.0/24)
    • Firewall subnet, dedicated to Azure Firewall (default 10.42.2.0/24, must be named AzureFirewallSubnet)
  • Azure Firewall with a public IP, with all outbound traffic from the AKS cluster and VM routed through it for inspection. Network rules allow:

    • NTP (UDP 123) for time synchronization
    • DNS (UDP 53) to Azure DNS
    • Service tags for Azure Container Registry, Microsoft Container Registry, Azure Active Directory, and Azure Monitor
    • TCP 9000 and UDP 1194 for Azure API and VPN access

    Application rules allow HTTP/HTTPS access to the FQDNs required for:

    • Diagrid services (*.diagrid.io)
    • Kubernetes image and package downloads
    • GitHub and container registries (Azure, AWS, GCP, Docker)
    • Ubuntu and Linux package repositories
    • Azure management and monitoring endpoints
    • Helm and Bitnami chart repositories
    • AKS-specific FQDN tags for Kubernetes operations
    • The management VM accessing the private AKS API server via the firewall

    A DNAT rule allows SSH (port 22) from your client IP to the VM's private IP via the firewall's public IP for secure, auditable management access.

  • Route Table — sends all traffic from the primary subnet to the firewall via a 0.0.0.0/0 route with the firewall's private IP as the next hop

  • AKS cluster — private cluster (API server not internet-exposed), 3 nodes by default, deployed in the primary subnet, with User Defined Routing forcing all outbound traffic through the firewall and Managed Identity enabled

  • Management VM — Ubuntu jumpbox in the primary subnet with no public IP (accessed via the firewall DNAT rule), with Managed Identity and the Contributor role assigned

Workflow support

At its core, Catalyst Enterprise Self-Hosted hosts the Dapr Workflow API, providing a foundation for building long-running, stateful workflows with built-in fault tolerance, scalability, and visualization.

Workflow visualization in the Catalyst console requires PostgreSQL to store the workflow data. You can either use a single PostgreSQL instance for both workflow visualization and workflow state, or bring your own workflow state store. This guide installs an in-cluster PostgreSQL instance via the Bitnami Helm chart and uses it for both purposes.

If you choose to enable Workflow support, the script configures Catalyst as follows:

  • Creates a dedicated catalyst database via the PostgreSQL Helm chart
  • Sets default credentials (postgres/postgres) for initial setup
  • Configures internal Kubernetes service discovery via postgres-postgresql.postgres.svc.cluster.local
  • Sets agent.config.project.default_managed_state_store_type: postgresql-shared-external and the matching connection string parameters

If you forgo the Workflow setup, you can still use Catalyst Enterprise Self-Hosted with the other supported Dapr APIs (service invocation, pub/sub, state management, and so on).

note

To visualize workflows through the Catalyst console, ensure your network rules allow access to the Catalyst UI from your environment.

Installation steps

1. Create a Catalyst region

Use the Diagrid CLI to create a new region and capture a join token:

diagrid login

export PRIVATE_REGION="azure-region"
export AZURE_API_KEY="azure-key"

# Create a new region and capture the join token
export JOIN_TOKEN=$(diagrid region create $PRIVATE_REGION \
--ingress "http://*.10.42.1.180.nip.io:8080" | jq -r .joinToken)

# Create an API key for CLI use from the management VM
# Note: this API key expires after 24 hours. Remove --duration for a long-lived key.
export API_KEY=$(diagrid apikey create \
--name $AZURE_API_KEY \
--role cra.diagrid:editor \
--duration 86400 | jq -r .token)

2. Log in to Azure

az login

3. Deploy Azure resources

Run the provided install.sh script to provision the Azure architecture described above. The script creates the resource group, VNet, subnets, firewall, AKS cluster, and management VM, and applies the routing and security rules — no manual configuration of these resources is required.

chmod +x ./install.sh
./install.sh

4. Connect to the Azure VM

The provisioning step also generates a connect.sh helper that establishes an SSH session to the management VM through the firewall:

chmod +x ./connect.sh
./connect.sh
note

From this point on, commands should be executed on the Azure VM unless stated otherwise.

5. Prepare the Azure VM

Install the CLI tooling and dependencies on the VM, source the generated environment, and authenticate the Diagrid CLI:

chmod +x "$HOME/setup.sh"
"$HOME/setup.sh"
rm "$HOME/setup.sh"

# Source the environment generated by install.sh
source .env

# Authenticate the Diagrid CLI
diagrid login --api-key="$API_KEY"

6. Install PostgreSQL for Dapr Workflows

To use Dapr Workflow with Catalyst Enterprise Self-Hosted, deploy a PostgreSQL instance into the AKS cluster. This step is optional.

helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update
helm install postgres bitnami/postgresql \
--set auth.postgresPassword=postgres \
--set auth.database=catalyst \
--create-namespace \
--namespace postgres

7. Configure and install Catalyst

Create a Helm values file. Use the with PostgreSQL variant if you installed PostgreSQL above; otherwise use the without PostgreSQL variant.

With PostgreSQL:

agent:
config:
project:
default_managed_state_store_type: postgresql-shared-external
external_postgresql:
enabled: true
auth_type: connectionString
namespace: postgres
connection_string_host: postgres-postgresql.postgres.svc.cluster.local
connection_string_port: 5432
connection_string_username: postgres
connection_string_password: postgres
connection_string_database: catalyst
gateway:
envoy:
service:
type: LoadBalancer
podAnnotations:
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
service.beta.kubernetes.io/azure-load-balancer-ipv4: 10.42.1.180

Without PostgreSQL:

agent:
config:
project:
default_managed_state_store_type: postgresql-shared-disabled
gateway:
envoy:
service:
type: LoadBalancer
podAnnotations:
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
service.beta.kubernetes.io/azure-load-balancer-ipv4: 10.42.1.180

Install Catalyst:

helm install catalyst oci://public.ecr.aws/diagrid/catalyst \
-n cra-agent \
--create-namespace \
-f catalyst-values.yaml \
--set join_token="${JOIN_TOKEN}" \
--version 1.53.0

Wait for all pods to be ready:

kubectl -n cra-agent wait --for=condition=ready pod --all --timeout=5m

8. Get started

Create a Catalyst project in the newly deployed region:

export PROJECT_NAME="azure-project"

diagrid project create $PROJECT_NAME --region $PRIVATE_REGION
diagrid project use $PROJECT_NAME

Create App IDs in the new project to test resource creation and connectivity:

diagrid appid create app1
diagrid appid create app2

# Wait until the App IDs are ready
diagrid appid list

# See the Dapr runtime instances running in Kubernetes
kubectl get po -A | grep prj

Open a new SSH session to the Azure VM and start a listener for app1:

./connect.sh

# Wait until you see:
# ✅ Connected App ID "app1" to http://localhost:<port> ⚡️
diagrid listen -a app1

From the original SSH session, send a service-invocation request between App IDs:

diagrid call invoke get app1.hello -a app2

You should see the request received on the app1 listener:

{
"method": "GET",
"url": "/hello"
}

This proves that you can use Dapr's service invocation API by calling an App ID over the private gateway IP. In this scenario the Diagrid CLI is acting as both the sending and receiving applications — see Test Catalyst APIs using the Diagrid CLI.

To continue, see the Connect to Catalyst guide for guidance on building and deploying your own apps.

Managed Identities

Catalyst App IDs running on AKS can authenticate to Azure services — such as Azure Key Vault or Azure Storage — using Azure Workload Identity instead of storing long-lived secrets. A single user-assigned managed identity is shared across the region and federated to each App ID's sidecar service account, so the sidecar (and your application) can exchange the service account token for an Azure AD token at runtime.

Prerequisites

Workload identity requires the OIDC issuer and the workload identity add-on to be enabled on the AKS cluster. The install.sh script enables both when it provisions the cluster. To enable them on an existing cluster:

az aks update \
--name "$CLUSTER_NAME" \
--resource-group "$RESOURCE_GROUP" \
--enable-oidc-issuer \
--enable-workload-identity

Entra ID also requires the tokens the sidecar presents to carry the api://AzureADTokenExchange audience. Configure Sentry — the Catalyst certificate authority that issues the sidecar's tokens — to include it by adding the following to your Helm values file (catalyst-values.yaml from the installation step) and upgrading the release:

global:
sentry:
jwt_audiences: ["api://AzureADTokenExchange"]

How it works

  • A single Catalyst-wide user-assigned managed identity (named catalyst) is shared by all App IDs in the region.
  • Each App ID's sidecar runs under its own Kubernetes service account, named sidecar-<project-uid>-<appid-uid> in the prj-<project-uid> namespace.
  • A federated credential links that service account — via the cluster's OIDC issuer — to the managed identity, so Azure trusts tokens issued for the service account.
  • Each App ID is labeled to opt its sidecar pod into Azure token injection and annotated with the identity's client ID, using the diagrid appid update CLI command.

The setup-user-managed-catalyst-identity.sh helper script in guides/azure automates the steps below for a single App ID. Run it with --project and --app (and optionally --keyvault / --storage-account), or follow the steps manually as described here.

Configure a managed identity manually

The following steps reproduce the helper script for one App ID. The catalyst identity (step 3) and its role assignments (step 5) are created once for the region; repeat steps 2, 4, and 6 for each additional App ID, since each App ID needs its own federated credential and its own workload identity label and annotation.

1. Set environment variables

export RESOURCE_GROUP="rg1"
export LOCATION="westeurope"
export CLUSTER_NAME="catalyst-cluster"
export CATALYST_PROJECT="azure-project"
export CATALYST_APP="app1"

2. Resolve the sidecar service account

The sidecar's service account name and namespace are derived from the project and App ID UIDs. Resolve them with the Diagrid CLI:

PROJECT_UID=$(diagrid project get "$CATALYST_PROJECT" --output json | jq -r '.metadata.uid')
APPID_UID=$(diagrid appid get "$CATALYST_APP" --project "$CATALYST_PROJECT" --output json | jq -r '.metadata.uid')

SERVICE_ACCOUNT_NAMESPACE="prj-${PROJECT_UID}"
SERVICE_ACCOUNT_NAME="sidecar-${PROJECT_UID}-${APPID_UID}"

3. Create the managed identity

Create the shared user-assigned managed identity (skip if it already exists) and capture its client and principal IDs:

az identity create \
--name catalyst \
--resource-group "$RESOURCE_GROUP" \
--location "$LOCATION"

USER_ASSIGNED_CLIENT_ID=$(az identity show \
--name catalyst --resource-group "$RESOURCE_GROUP" \
--query clientId --output tsv)

PRINCIPAL_ID=$(az identity show \
--name catalyst --resource-group "$RESOURCE_GROUP" \
--query principalId --output tsv)

4. Create the federated credential

Look up the cluster's OIDC issuer URL and federate the sidecar service account to the identity:

AKS_OIDC_ISSUER=$(az aks show \
--name "$CLUSTER_NAME" --resource-group "$RESOURCE_GROUP" \
--query "oidcIssuerProfile.issuerUrl" --output tsv)

az identity federated-credential create \
--name "catalyst-${PROJECT_UID}-${APPID_UID}" \
--identity-name catalyst \
--resource-group "$RESOURCE_GROUP" \
--issuer "$AKS_OIDC_ISSUER" \
--subject "system:serviceaccount:${SERVICE_ACCOUNT_NAMESPACE}:${SERVICE_ACCOUNT_NAME}" \
--audience api://AzureADTokenExchange

5. (Optional) Grant access to Azure resources

Grant the identity the data-plane roles it needs. For example, read access to a Key Vault and read/write access to a storage account:

# Key Vault Secrets User
KEYVAULT_SCOPE=$(az keyvault show --name "<keyvault-name>" --query id --output tsv)
az role assignment create \
--assignee-object-id "$PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--role "Key Vault Secrets User" \
--scope "$KEYVAULT_SCOPE"

# Storage Blob Data Contributor
STORAGE_SCOPE=$(az storage account show --name "<storage-account-name>" --query id --output tsv)
az role assignment create \
--assignee-object-id "$PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--role "Storage Blob Data Contributor" \
--scope "$STORAGE_SCOPE"

6. Enable workload identity on the App ID

Apply the workload identity label and annotation to the App ID with the Diagrid CLI, using the client ID captured in step 3. Diagrid applies them to the App ID's sidecar pod and service account:

diagrid appid update "$CATALYST_APP" \
--project "$CATALYST_PROJECT" \
--label azure.workload.identity/use=true \
--annotation azure.workload.identity/client-id="$USER_ASSIGNED_CLIENT_ID"

The azure.workload.identity/use=true label opts the sidecar pod into Azure token injection, and the azure.workload.identity/client-id annotation tells the workload identity webhook which managed identity to use.

note

--label and --annotation replace the App ID's existing workload labels and annotations, so pass every label and annotation the App ID needs in a single command. Both flags are only supported in self-hosted regions.

Because all App IDs share the single catalyst identity, the same client ID applies to every App ID — only the per-App ID federated credential from step 4 differs. Each App ID must be labeled and annotated individually. Once the sidecar restarts, it runs with the federated managed identity and can access any Azure resource the identity is authorized for.

Catalyst Identity

Catalyst App IDs can also authenticate to Azure services using their own Catalyst workload identity — the SPIFFE identity Catalyst issues to each App ID — instead of borrowing an AKS service account. Rather than trusting the AKS cluster's OIDC issuer, the Azure federated credential trusts the Catalyst region's OIDC issuer and the App ID's SPIFFE ID, so the sidecar exchanges its JWT-SVID for an Azure AD token at runtime, regardless of which cluster it runs on. Use this approach when you want the Azure identity tied to the Catalyst App ID itself rather than to the AKS service account that happens to host its sidecar.

Prerequisite

Entra ID only accepts federated tokens whose audience matches the federated credential's, so the JWT-SVIDs the sidecar presents must carry the api://AzureADTokenExchange audience. Configure Sentry — the Catalyst certificate authority that issues the JWT-SVIDs — to include it by adding the following to your Helm values file (catalyst-values.yaml from the installation step) and upgrading the release:

global:
sentry:
jwt_audiences: ["api://AzureADTokenExchange"]

How it works

  • An Azure AD application (app registration) scoped to the project — named catalyst-<project> — backs the identity. Its application (client) ID is what your Azure component authenticates as.
  • Each App ID carries its own Catalyst workload identity, expressed as one or more SPIFFE IDs — one per region host its sidecar may run on — published in the App ID's status (.status.spiffeIds).
  • A federated credential on the app trusts the Catalyst region's OIDC issuer and one of the App ID's SPIFFE IDs, so Azure accepts the JWT-SVID the sidecar mints for that Catalyst identity (audience api://AzureADTokenExchange).
  • The app's service principal is granted the Azure roles the workload needs.
  • The Azure component is configured with the app's client and tenant IDs — no client secret required.

The setup-federated-catalyst-identity.sh helper script in guides/azure automates the steps below for a single App ID. Run it with --project and --app (and optionally --keyvault / --storage-account), or follow the steps manually as described here. The OIDC issuer is resolved automatically from the project's region.

Configure a federated identity manually

The following steps reproduce the helper script for one App ID. The per-project app registration (step 3) and its role assignments (step 5) are created once per project and shared by its App IDs; repeat steps 2 and 4 for each additional App ID, since each needs its own federated credential.

1. Set environment variables

export CATALYST_PROJECT="azure-project"
export CATALYST_APP="app1"
# One app registration per project
export APP_DISPLAY_NAME="catalyst-${CATALYST_PROJECT}"

# The Catalyst OIDC issuer URL, resolved from the project's region
REGION_ID=$(diagrid project get "$CATALYST_PROJECT" --output json | jq -r '.spec.region')
export ISSUER=$(diagrid region get "$REGION_ID" --output json | jq -r '.status.endpoints.oidc')

2. Resolve the App ID's SPIFFE identities

A sidecar may run on more than one region host, each with its own SPIFFE ID. Read them from the App ID's status with the Diagrid CLI into a newline-delimited list:

SPIFFE_IDS=$(diagrid appid get "$CATALYST_APP" --project "$CATALYST_PROJECT" \
--output json | jq -r '.status.spiffeIds // [.status.spiffeId] | .[]')

3. Create the app registration

Create the project's app registration (skip if it already exists) and its service principal, then capture the application ID:

APP_ID=$(az ad app list --display-name "$APP_DISPLAY_NAME" --query "[0].appId" --output tsv)
[ -z "$APP_ID" ] && APP_ID=$(az ad app create --display-name "$APP_DISPLAY_NAME" --query appId --output tsv)

SP_OBJECT_ID=$(az ad sp show --id "$APP_ID" --query id --output tsv 2>/dev/null \
|| az ad sp create --id "$APP_ID" --query id --output tsv)

4. Create a federated credential per SPIFFE ID

Federate each of the App ID's SPIFFE identities to the app, trusting the region's OIDC issuer:

echo "$SPIFFE_IDS" | while IFS= read -r SUBJECT; do
AUTHORITY=${SUBJECT#spiffe://}; HOST_LABEL=${AUTHORITY%%.*}
cat > fic.json <<EOF
{
"name": "${CATALYST_APP}-${HOST_LABEL}",
"issuer": "${ISSUER}",
"subject": "${SUBJECT}",
"audiences": ["api://AzureADTokenExchange"]
}
EOF
az ad app federated-credential create --id "$APP_ID" --parameters @fic.json
done

5. (Optional) Grant access to Azure resources

Grant the app's service principal the data-plane roles it needs. For example, read access to a Key Vault and read/write access to a storage account:

# Key Vault Secrets User
KEYVAULT_SCOPE=$(az keyvault show --name "<keyvault-name>" --query id --output tsv)
az role assignment create \
--assignee-object-id "$SP_OBJECT_ID" \
--assignee-principal-type ServicePrincipal \
--role "Key Vault Secrets User" \
--scope "$KEYVAULT_SCOPE"

# Storage Blob Data Contributor
STORAGE_SCOPE=$(az storage account show --name "<storage-account-name>" --query id --output tsv)
az role assignment create \
--assignee-object-id "$SP_OBJECT_ID" \
--assignee-principal-type ServicePrincipal \
--role "Storage Blob Data Contributor" \
--scope "$STORAGE_SCOPE"

6. Configure the Azure component

Configure the Azure component the App ID uses with the application's client and tenant IDs. The federated credential supplies the token, so no azureClientSecret is required:

TENANT_ID=$(az account show --query tenantId --output tsv)

diagrid component create my-keyvault \
--type secretstores.azure.keyvault \
--project "$CATALYST_PROJECT" \
--metadata vaultName="<keyvault-name>" \
--metadata azureClientId="$APP_ID" \
--metadata azureTenantId="$TENANT_ID" \
--scopes "$CATALYST_APP"
note

Federated identities rely on the App ID's SPIFFE workload identity, which is only available in self-hosted regions. Re-run step 4 whenever the App ID's set of region hosts changes, so every SPIFFE ID has a matching federated credential.

Once the component is scoped to the App ID, its sidecar presents the App ID's Catalyst identity — its SPIFFE ID — in exchange for an Azure AD token, and can access any Azure resource the app's service principal is authorized for. Because the credential is bound to the Catalyst App ID rather than to an AKS service account, the same Azure identity applies wherever the App ID's sidecar runs.

Configuration options

Network configuration

  • VNet address spaceADDRESS_PREFIX (default 10.42.0.0/16)
  • Subnet rangesSUBNET_PREFIX (default 10.42.1.0/24)
  • Load balancer IPLOADBALANCER_IPV4 (default 10.42.1.180)

Compute resources

  • AKS node count — default 3 nodes, configurable in install.sh
  • VM size — default Standard_B2s (2 vCPU, 4 GiB), set via VM_SIZE
  • AKS node pool — VM sizes and autoscaling are configurable in the script

Security customization

  • Firewall rules — extend the FQDN allowlist for additional services
  • Network rules — add ports or protocols as needed
  • Access control — adjust SSH access rules and IP restrictions

Workflow configuration

  • PostgreSQL — optional, enables Workflow visualization and state storage
  • External state stores — Catalyst supports custom external PostgreSQL instances
  • Database credentials — configurable via Helm values

Limitations

  • Secrets backends — Catalyst Enterprise Self-Hosted supports AWS Secrets Manager and Kubernetes Secrets. Azure Key Vault support is on the roadmap.