Agent Observability Platform: Installation (Azure)

Deploy the Agent Observability data platform on Azure with Terraform

☁️

This page covers Azure. Also available for AWS and GCP.

Configure and apply the Terraform module to deploy the platform.

πŸ“˜

Prerequisites. Complete the Prerequisites first β€” tooling, Azure permissions, resource providers, the DNS zone, and the platform artifacts.

Overview

The terraform-azurerm-ao-data-platform module provisions the AKS cluster and, by default (helm.deploy_charts = true), also deploys the ao-data-platform Helm chart β€” ClickHouse, the OpenTelemetry Collector, and the LLM worker β€” in the same terraform apply.

The kubernetes and helm providers are configured from the module's outputs, which lets Terraform defer the Kubernetes/Helm resources until after the AKS cluster exists. This is what enables a single-pass apply.

Work through the steps below in order. The required inputs are the same for both cluster paths: location, otel_collector_domain, clickhouse_domain, dns_zone, helm.chart_registry, helm.chart_version, and helm.llm_worker.foundry (the Claude evaluation backend β€” organization_name is the Azure Marketplace attestation and has no default).

πŸ“˜

The examples below install the public artifacts: the Terraform module from the Terraform Registry (monte-carlo-data/ao-data-platform/azurerm), and the ao-data-platform Helm chart and ao-llm-worker image from Docker Hub (see Prerequisites). Pulling them requires no registry authentication.

1. Configure the providers

Your root module must configure the azurerm, kubernetes, and helm providers. The kubernetes and helm providers are wired from the module's outputs and authenticate through kubelogin:

terraform {
  required_providers {
    azurerm    = { source = "hashicorp/azurerm", version = "~> 4.0" }
    kubernetes = { source = "hashicorp/kubernetes", version = "~> 2.0" }
    helm       = { source = "hashicorp/helm", version = "~> 2.0" }
  }
}

provider "azurerm" {
  features {}
}

provider "kubernetes" {
  host                   = module.ao_data_platform.aks_cluster_endpoint
  cluster_ca_certificate = module.ao_data_platform.aks_cluster_ca_certificate
  exec {
    api_version = "client.authentication.k8s.io/v1beta1"
    command     = "kubelogin"
    args        = ["get-token", "--login", "azurecli", "--server-id", "6dae42f8-4368-4678-94ff-3960e28e3630"]
  }
}

provider "helm" {
  kubernetes {
    host                   = module.ao_data_platform.aks_cluster_endpoint
    cluster_ca_certificate = module.ao_data_platform.aks_cluster_ca_certificate
    exec {
      api_version = "client.authentication.k8s.io/v1beta1"
      command     = "kubelogin"
      args        = ["get-token", "--login", "azurecli", "--server-id", "6dae42f8-4368-4678-94ff-3960e28e3630"]
    }
  }
}

The --server-id value is the well-known, tenant-independent Entra ID application ID of the AKS API server β€” it is the same for every AKS cluster, so use it verbatim.

❗️

Pin the helm provider to ~> 2.0. Version 3 of the provider changed the kubernetes block to a top-level argument and is not yet supported β€” installing the latest provider fails with a configuration-syntax error.

2. Configure the module

New cluster

The module creates the resource group, VNet, and AKS cluster with sensible defaults (cluster name monte-carlo). A module-created cluster always runs the zoned ClickHouse/Keeper topology, so clickhouse_zones and keeper_zones are required on this path. The example below deploys the standard highly available topology β€” two ClickHouse replicas across two availability zones, coordinated by a three-voter Keeper ensemble:

module "ao_data_platform" {
  source  = "monte-carlo-data/ao-data-platform/azurerm"
  version = "~> 1.0"

  location              = "eastus"
  resource_group_name   = "ao-data-platform"
  otel_collector_domain = "otel.acme.com"
  clickhouse_domain     = "clickhouse.acme.com"

  dns_zone                     = "acme.com"
  dns_zone_resource_group_name = "dns"

  # Two ClickHouse replicas across two zones, backed by a 3-voter Keeper ensemble.
  clickhouse_zones         = ["1", "2"]
  keeper_zones             = ["1", "2", "3"]
  clickhouse_replica_count = 2

  helm = {
    chart_registry = "oci://registry-1.docker.io/montecarlodata"
    chart_version  = "4.0.0"

    llm_worker = {
      image_tag = "1.1.0-azure"

      foundry = {
        # Required β€” the Azure Marketplace attestation for the Anthropic offer.
        organization_name = "Acme Inc."
        # account_name = "acme-ao-foundry"  # a GLOBALLY unique subdomain; defaults to "<cluster name>-foundry"
      }
    }
  }
}

See examples/new_cluster/ for a complete, copy-paste starting point including the provider blocks.

πŸ“˜

Dev/testing: for an environment where zone-failure tolerance isn't worth the node count, run a single replica with a single Keeper voter β€” see the single-replica variant. Zone entries are Azure availability-zone numbers ("1", "2", "3"); a single AKS node subnet is regional, so no per-zone subnets are needed. Also override networking.vnet_address_space β€” the default 10.18.0.0/16 is a placeholder.

Existing cluster

Set cluster.create = false and networking.create_vnet = false, and provide the cluster's name and resource group, plus the VNet and the AKS node subnet (entry [0]):

module "ao_data_platform" {
  source  = "monte-carlo-data/ao-data-platform/azurerm"
  version = "~> 1.0"

  location = "eastus"  # must match the existing cluster's region

  cluster = {
    create                       = false
    existing_cluster_name        = "my-cluster"
    existing_resource_group_name = "my-cluster-rg"
  }

  networking = {
    create_vnet      = false
    existing_vnet_id = "/subscriptions/.../virtualNetworks/my-vnet"
    existing_subnet_ids = [
      "/subscriptions/.../subnets/aks", # [0] AKS node subnet (required)
    ]
  }

  otel_collector_domain = "otel.acme.com"
  clickhouse_domain     = "clickhouse.acme.com"
  dns_zone              = "acme.com"

  helm = {
    chart_registry = "oci://registry-1.docker.io/montecarlodata"
    chart_version  = "4.0.0"

    llm_worker = {
      image_tag = "1.1.0-azure"

      foundry = {
        organization_name = "Acme Inc."
      }
    }

    # Set any controller already present in the cluster to false:
    # install_cert_manager              = false
    # install_external_secrets_operator = false
    # install_external_dns              = false
  }
}

See examples/existing_cluster/ for the full configuration. The cluster must already have its OIDC issuer, Workload Identity, and the Gateway API / app-routing-Istio add-ons enabled β€” the module verifies the add-ons at plan time and fails with a pointer to the az aks update commands if they are missing (see Prerequisites).

πŸ“˜

On an existing cluster the module does not create the ClickHouse and Keeper node pools (and rejects clickhouse_zones / keeper_zones at plan time) β€” you attach tainted per-zone node pools yourself and take the self-managed Helm route (helm.deploy_charts = false) to wire the scheduling values, since the module-managed release doesn't expose them. See High availability β€” existing-cluster deployments.

πŸ“˜

helm.chart_registry is the registry prefix only β€” oci://registry-1.docker.io/montecarlodata, with no chart name. The module appends /ao-data-platform itself, so adding it here (.../montecarlodata/ao-data-platform) makes terraform apply fail to pull the chart. The artifacts table in Prerequisites lists the full chart path because that's the chart's location β€” but as a module input, pass only the prefix.

3. Initialize Terraform

terraform init

This downloads the module and the azurerm, kubernetes, and helm providers (plus the module's internal providers, including azapi).

4. Review the plan

terraform plan

Review the planned changes before applying. On the new-cluster path the plan creates the resource group, VNet with a NAT gateway (stable egress IP), the AKS cluster and node pools β€” including one per listed ClickHouse and Keeper zone β€” user-assigned managed identities with federated credentials, a Key Vault with the ClickHouse credential secrets and the etcd encryption key, the Foundry (Claude) evaluation account with its model deployments and a custom least-privilege inference role, and the Helm releases.

❗️

Check the availability zones. Azure managed disks are zone-locked, so ClickHouse and Keeper placement is driven by the explicit zone numbers you listed β€” see ClickHouse and Keeper node pools below. The module verifies at plan time that clickhouse_zones and keeper_zones are non-empty, that clickhouse_replica_count does not exceed the length of clickhouse_zones, and that keeper_zones has an odd length. If the plan fails one of these preconditions, fix the zone lists rather than overriding the guard.

5. Apply

terraform apply

Review the plan once more, then confirm. The apply provisions the Azure infrastructure and (with helm.deploy_charts = true) deploys the chart in one pass. Expect a long apply on the new-cluster path: most of it is the AKS cluster and node pools, several deliberate pauses (30–60 seconds each) for Azure RBAC propagation, and the Helm release itself (the HA shape takes about 6–8 minutes inside a single Helm wait; the release timeout is 900 seconds).

πŸ“˜

apply modifies your ~/.kube/config. To enable the single-pass deploy, the module runs az aks get-credentials and kubelogin convert-kubeconfig inside local-exec provisioners β€” it has to kubectl wait for the External Secrets Operator CRDs and apply a ClusterSecretStore, which the native Terraform kubernetes/helm providers can't do for resources created in the same apply. This adds or refreshes the cluster's context in the ~/.kube/config of the machine running Terraform and makes it the current context. It happens on every apply, on both cluster paths.

πŸ“˜

To manage the Helm release yourself instead, set helm.deploy_charts = false and follow the self-managed Helm install.

6. Confirm what was created

terraform output

You should see (among others):

OutputWhat it is
aks_cluster_nameThe cluster name β€” use it with az aks get-credentials next
montecarlo_namespaceThe namespace (montecarlo) all components run in
key_vault_uriThe Key Vault holding the ClickHouse credentials
clickhouse_monte_carlo_credentials_secret_idKey Vault secret ID for the monte_carlo user β€” the credential you hand to Monte Carlo
clickhouse_otel_credentials_secret_idSecret ID for the otel ingest user (OpenTelemetry Collector)
clickhouse_schema_owner_credentials_secret_id / clickhouse_llm_worker_credentials_secret_idSecret IDs for the schema_owner (migrations / MV owner) and llm_worker users
clickhouse_admin_credentials_secret_id / clickhouse_readonly_user_credentials_secret_idSecret IDs for the optional admin and readonly_user β€” null when those users are disabled
oidc_issuer_urlThe cluster's OIDC issuer β€” used to federate Workload Identities (for example, the Monte Carlo Agent's)
llm_worker_identity_client_idThe LLM worker's Workload Identity client ID
foundry_account_nameThe Foundry (Claude) evaluation account β€” also its globally unique subdomain
clickhouse_node_poolsThe dedicated per-zone ClickHouse node pools, keyed by pool name (ch<zone>) β†’ { zone, vm_size } β€” verify the resolved zones; null on the existing-cluster path or when charts are off

The Azure infrastructure is now provisioned and the chart is deploying. Verifying that the in-cluster components (ClickHouse, the Collector, the LLM worker) came up healthy is the first step of Deploy the agent and connect to Monte Carlo.

ClickHouse and Keeper node pools

Every ClickHouse replica and every Keeper voter is a stateful pod backed by a zone-locked Azure managed disk, so each one runs on its own dedicated single-zone AKS node pool. On the new-cluster path (with helm.deploy_charts = true) the module creates them all automatically:

  • Per-zone ClickHouse node pools β€” one per entry in clickhouse_zones (named ch<zone>, one node each, tainted dedicated=clickhouse:NoSchedule, VM size Standard_E8s_v5 by default).
  • Per-zone Keeper node pools β€” one per entry in keeper_zones (named keeper<zone>, one node each, tainted dedicated=keeper:NoSchedule, VM size Standard_D2as_v4 by default).

Both zone lists are required on this path β€” there is no single-node layout; a single zone in each list is the low-cost dev floor.

The module wires the matching nodeSelector/tolerations into the Helm release, so the ClickHouse and Keeper pods target their node pools exclusively β€” no manual configuration required. The OpenTelemetry Collector, LLM worker, and cluster controllers run on the main node pool. Node images are pinned by default (node_os_upgrade_channel = "None"), so routine applies never roll the stateful nodes implicitly β€” see High availability β€” node-image updates.

❗️

Managed disks are zone-locked. Placement is by the explicit zone numbers in the topology lists β€” a volume cannot follow a pod to a different zone. clickhouse_replica_count may not exceed the number of listed ClickHouse zones (enforced at plan time), and both lists must name zones your region and subscription actually offer.

On the existing-cluster path, the module does not manage these node pools β€” attach tainted per-zone node pools yourself and take the self-managed Helm route (helm.deploy_charts = false) to pass the matching scheduling values, since the module-managed release doesn't expose them. See High availability β€” existing-cluster deployments.

Harden network access

Both endpoints are served by a single managed Gateway on an internal load balancer β€” they are never on the public internet, and clients reach them over private connectivity (VNet, peering, or VPN). Two further controls are worth setting before you rely on the deployment:

  • Gateway source ranges. gateway.allowed_source_ranges restricts which sources can reach the Gateway's load balancer: null (the default) applies no restriction, [] restricts to the VNet address space, and a CIDR list allows the VNet space plus the listed ranges β€” the VNet space is always folded in so a restriction can't lock out in-VNet clients. On a bring-your-own VNet the module doesn't know your address space β€” include it in the list explicitly. See Network access.
  • AKS API endpoint. By default a new cluster's API server is reachable from anywhere. Set cluster.api_server_authorized_ip_ranges to allow-list source ranges, or cluster.private_cluster_enabled = true for a fully private control plane (setting both is rejected). Account for every machine that talks to the API β€” terraform apply itself needs it (providers and provisioners), and so does CI: hosted runners without stable egress IPs can't be allow-listed, and a private cluster needs a private path (VPN, peering, private endpoint, or an in-VNet runner) for every apply and kubectl invocation.

Egress from module-created VNets is routed through a NAT gateway with a static public IP, so outbound traffic (Docker Hub pulls, Let's Encrypt, the Foundry endpoint) presents a stable source address you can allow-list elsewhere.

Next steps

Continue to Deploy the agent and connect to Monte Carlo.


Did this page help you?