# 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.

:::warning 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](./production-planning.mdx) guidance and adapt this script to your organization's networking, security, and operational requirements before going to production.

:::

## Prerequisites

### Required tools

- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) (v2.45.0+) — for managing Azure resources and authentication
- [Diagrid CLI](../../../references/catalyst/catalyst-cli.mdx) (v0.12.0+) — for managing Catalyst regions and projects
- [Helm](https://helm.sh/) (v3.12.0+) — for deploying Catalyst to Kubernetes
- [kubectl](https://kubernetes.io/docs/tasks/tools/) (v1.28+) — for interacting with the Kubernetes cluster
- [jq](https://stedolan.github.io/jq/download/) (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](https://learn.microsoft.com/en-us/windows/wsl/install) or [Git Bash](https://git-scm.com/downloads) 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`](https://github.com/diagridio/charts/tree/main/guides/azure) directory of the [`diagridio/charts`](https://github.com/diagridio/charts) repository. Clone the repository and run the scripts from inside that directory:

```bash
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](https://learn.microsoft.com/en-us/azure/architecture/reference-architectures/containers/aks/aks-baseline.png). 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.

```mermaid
---
title: Azure reference architecture
---
flowchart TB
  Admin(("Admin client (SSH)")):::pub

  subgraph Azure
    direction TB
    FW("Azure Firewall<br/>AzureFirewallSubnet 10.42.2.0/24<br/>+ public IP"):::customerNode
    VM("Management VM (jumpbox)"):::customerNode
    subgraph AKS["AKS private cluster <br/>Catalyst dataplane"]
      direction LR
      ILB("Internal LB<br/>10.42.1.180"):::customerNode
      DAPR("Dapr<br/>deployment")
      AGENT("Catalyst<br/>agent")
      PG[("In-cluster<br/>PostgreSQL<br/>(optional)")]:::customerNode
    end
  end

  subgraph Diagrid["Diagrid Cloud"]
    CP("Catalyst control plane")
  end

  Admin--"SSH via DNAT"-->FW
  FW-->VM
  VM-->AKS
  ILB-->DAPR
  AGENT<-->DAPR
  AGENT--"egress via firewall"-->FW
  FW<--"mTLS"-->CP
  DAPR-->PG

  style Diagrid stroke:#22613f,stroke-width:2px
  style Azure stroke:#ed8936,stroke-width:2px
  style AKS stroke-dasharray:4 3,stroke-width:1.5px
  classDef customerNode stroke:#ed8936,stroke-width:2px
  classDef pub stroke:#999,stroke-width:1px
```

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](https://docs.dapr.io/developing-applications/building-blocks/workflow/workflow-overview/), 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:

```bash
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

```bash
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.

```bash
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:

```bash
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:

```bash
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.

```bash
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:**

```yaml
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
gateway:
  envoy:
    service:
      type: LoadBalancer
      annotations:
        service.beta.kubernetes.io/azure-load-balancer-internal: "true"
        service.beta.kubernetes.io/azure-load-balancer-ipv4: 10.42.1.180
```

**Without PostgreSQL:**

Turning off the managed PostgreSQL state store also removes the backing store for the Dapr scheduler, which is PostgreSQL-backed by default. Switch the scheduler to etcd in the same values file — otherwise the chart rejects the install with an incompatible-configuration error:

```yaml
global:
  postgresql:
    disabled: true
agent:
  config:
    internal_dapr:
      scheduler:
        backend_type: etcd
gateway:
  envoy:
    service:
      type: LoadBalancer
      annotations:
        service.beta.kubernetes.io/azure-load-balancer-internal: "true"
        service.beta.kubernetes.io/azure-load-balancer-ipv4: 10.42.1.180
```

The etcd scheduler claims a persistent volume sized by `agent.config.internal_dapr.scheduler.storage_size`, which defaults to `8Gi`.

Install Catalyst:

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

Wait for all pods to be ready:

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

### 8. Get started

Create a Catalyst project in the newly deployed region:

```bash
export PROJECT_NAME="azure-project"

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

Create [IDs](https://docs.diagrid.io/operate/project-operations/ids) in the new project to test resource creation and connectivity:

```bash
diagrid app create app1
diagrid app create app2

# Wait until the App IDs are ready
diagrid app 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`:

```bash
./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 apps:

```bash
diagrid call invoke get app1.hello -a app2
```

You should see the request received on the `app1` listener:

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

This proves that you can use [Dapr's service invocation API](https://docs.dapr.io/developing-applications/building-blocks/service-invocation/service-invocation-overview/) by calling an app 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](https://docs.diagrid.io/develop/local-development/catalyst-cli#smoke-testing-via-cli).

To continue, see the [Connect to Catalyst guide](https://docs.diagrid.io/develop/connect) for guidance on building and deploying your own apps.

## Managed Identities

Catalyst apps running on AKS can authenticate to Azure services — such as Azure Key Vault or Azure Storage — using [Azure Workload Identity](https://learn.microsoft.com/en-us/azure/aks/workload-identity-overview) instead of storing long-lived secrets. A single user-assigned managed identity is shared across the region and federated to each app's sidecar service account, so the sidecar (and your application) can exchange the service account token for an Azure AD token at runtime.

:::note 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:

```bash
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](#7-configure-and-install-catalyst)) and upgrading the release:

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

:::

### How it works

- A single Catalyst-wide user-assigned managed identity (named `catalyst`) is shared by all apps in the region.
- Each app'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 is labeled to opt its sidecar pod into Azure token injection and annotated with the identity's client ID, using the `diagrid app update` CLI command.

The `setup-user-managed-catalyst-identity.sh` helper script in [`guides/azure`](https://github.com/diagridio/charts/tree/main/guides/azure) automates the steps below for a single app. 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. 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, since each app needs its own federated credential and its own workload identity label and annotation.

#### 1. Set environment variables

```bash
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 UIDs. Resolve them with the Diagrid CLI:

```bash
PROJECT_UID=$(diagrid project get "$CATALYST_PROJECT" --output json | jq -r '.metadata.uid')
APPID_UID=$(diagrid app 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:

```bash
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:

```bash
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:

```bash
# 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

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

```bash
diagrid app 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's existing workload labels and annotations, so pass every label and annotation the app needs in a single command. Both flags are only supported in self-hosted regions.

:::

Because all apps share the single `catalyst` identity, the same client ID applies to every app — only the per-app federated credential from step 4 differs. Each app 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.

### Grant the management service access

When a workflow or agent state store references a secret in your own secret store — for example an Azure Key Vault credential referenced through a `secretKeyRef` — the regional Catalyst **management service** resolves that secret in-process in order to serve workflow and agent data. It authenticates as its own Kubernetes service account rather than any app's, so the shared `catalyst` identity must trust that service account too.

Create a federated credential for the management service account — `catalyst-management-sa` in the `cra-agent` namespace for the installation in this guide:

```bash
az identity federated-credential create \
  --name catalyst-management \
  --identity-name catalyst \
  --resource-group "$RESOURCE_GROUP" \
  --issuer "$AKS_OIDC_ISSUER" \
  --subject "system:serviceaccount:cra-agent:catalyst-management-sa" \
  --audience api://AzureADTokenExchange
```

Management is part of the Catalyst Helm release rather than a Catalyst app, so it is opted into workload identity through Helm values instead of `diagrid app update`. Add the following to your Helm values file (`catalyst-values.yaml` from the [installation step](#7-configure-and-install-catalyst)), using the client ID captured in step 3, and upgrade the release. The `merge.selectorLabels` entry adds the label to the management pods — it is merged into the pod template only, leaving the deployment's selector untouched, so the change is upgrade-safe:

```yaml
management:
  merge:
    selectorLabels:
      azure.workload.identity/use: "true"
  podAnnotations:
    azure.workload.identity/client-id: "<client-id>"
  serviceAccount:
    annotations:
      azure.workload.identity/client-id: "<client-id>"
```

Federating management only authenticates it — the identity it runs as must also be authorized on the resources it reads, in particular the secret store the `secretKeyRef` targets (for example `Key Vault Secrets User` on the vault). Because management shares the `catalyst` identity with the apps, the role assignments from step 5 grant that access to both at once. The federated credential is region-wide rather than per app, so this is a one-time setup. The `setup-user-managed-catalyst-identity.sh` helper script creates the federated credential automatically and prints the Helm values to apply.

## Catalyst Identity

Catalyst apps can also authenticate to Azure services using their own Catalyst workload identity — the [SPIFFE](https://spiffe.io/) identity Catalyst issues to each app — 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'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 itself rather than to the AKS service account that happens to host its sidecar.

:::note 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](#7-configure-and-install-catalyst)) and upgrading the release:

```yaml
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 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's status (`.status.spiffeIds`).
- A [federated credential](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation) on the app trusts the Catalyst region's OIDC issuer and one of the app'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`](https://github.com/diagridio/charts/tree/main/guides/azure) automates the steps below for a single app. 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. The per-project app registration (step 3) and its role assignments (step 5) are created once per project and shared by its apps; repeat steps 2 and 4 for each additional app, since each needs its own federated credential.

#### 1. Set environment variables

```bash
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's SPIFFE identities

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

```bash
SPIFFE_IDS=$(diagrid app 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:

```bash
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's SPIFFE identities to the app, trusting the region's OIDC issuer:

```bash
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:

```bash
# 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](https://docs.diagrid.io/references/catalyst/components-reference/secretstores/azure.keyvault) the app uses with the application's client and tenant IDs. The federated credential supplies the token, so no `azureClientSecret` is required:

```bash
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's SPIFFE workload identity, which is only available in self-hosted regions. Re-run step 4 whenever the app's set of region hosts changes, so every SPIFFE ID has a matching federated credential.

:::

Once the component is scoped to the app, its sidecar presents the app'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 rather than to an AKS service account, the same Azure identity applies wherever the app's sidecar runs.

### Federate the management service identity

When a workflow or agent state store references a secret in your own secret store — for example an Azure Key Vault credential referenced through a `secretKeyRef` — the regional Catalyst **management service** resolves that secret in-process in order to serve workflow and agent data. It authenticates with its own Catalyst identity rather than any app's: a SPIFFE ID that shares the app's region-host trust domain and differs only in its path, `spiffe://<region-trust-domain>/ns/cra-agent/management`. Federate that identity on the project's app registration so management can read the store.

Derive the management subjects from the app's SPIFFE IDs from step 2 — one per region host — and create a federated credential for each:

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

Federating management only authenticates it — the service principal it presents must also be authorized on the resources it reads, in particular the secret store the `secretKeyRef` targets (for example `Key Vault Secrets User` on the vault). Because management presents the same application (client) ID as the apps, the role assignments from step 5 grant that access to both at once. The management credentials are per region host and shared by all apps on the project's app registration, so create them once per project, and re-run the loop whenever the set of region hosts changes — just as with step 4. The `setup-federated-catalyst-identity.sh` helper script federates both the app's and management's subjects automatically.

## Configuration options

### Network configuration

- **VNet address space** — `ADDRESS_PREFIX` (default `10.42.0.0/16`)
- **Subnet ranges** — `SUBNET_PREFIX` (default `10.42.1.0/24`)
- **Load balancer IP** — `LOADBALANCER_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.
- **Multi-region** — a region built by this guide **cannot join a [region group](https://docs.diagrid.io/operate/platform-operations/multi-region)**. Its gateway sits behind an internal load balancer, and an Azure cross-region load balancer accepts only public Standard load balancer frontends in its backend pool. Building a group on Azure means the public regions the [Azure multi-region deployment](https://docs.diagrid.io/operate/hosting/enterprise-self-hosted/azure-multi-region-deployment) guide describes, from a Terraform stack of its own.
