Deployment of Service
This topic describes the steps to deploy the AI service and OTeL collector in the AKS provisioned after the completing Deployment of Infrastructure, using a pre-built docker image.
Assumptions
The following steps assume that the infrastructure (Terraform IaC) deployment for customer-hosted Codebeamer AI is completed successfully.
All required Azure resources and dependencies are expected to be available and properly configured.
Step 1: Deploy the OTel collector service
Deploy the OTel Collector on AKS that was created during Deployment of Infrastructure.
A sample Helm chart for the OTEL collector stack is provided with the service package helm-otel-chd/. Use it as a reference implementation. Review and update it for your environment before deploying.
1. Import Docker images to your private Azure Container Registry.
All four images must be available in your private Azure Container Registry before deployment.
ACR_NAME="<YOUR_ACR_NAME>"

# OTEL Collector
docker pull otel/opentelemetry-collector-contrib:latest
docker tag otel/opentelemetry-collector-contrib:latest ${ACR_NAME}.azurecr.io/otel/opentelemetry-collector-contrib:latest
docker push ${ACR_NAME}.azurecr.io/otel/opentelemetry-collector-contrib:latest

# Zipkin
docker pull openzipkin/zipkin:latest
docker tag openzipkin/zipkin:latest ${ACR_NAME}.azurecr.io/openzipkin/zipkin:latest
docker push ${ACR_NAME}.azurecr.io/openzipkin/zipkin:latest

# Prometheus
docker pull prom/prometheus:latest
docker tag prom/prometheus:latest ${ACR_NAME}.azurecr.io/prom/prometheus:latest
docker push ${ACR_NAME}.azurecr.io/prom/prometheus:latest

# Aspire Dashboard
docker pull mcr.microsoft.com/dotnet/aspire-dashboard:latest
docker tag mcr.microsoft.com/dotnet/aspire-dashboard:latest ${ACR_NAME}.azurecr.io/dotnet/aspire-dashboard:latest
docker push ${ACR_NAME}.azurecr.io/dotnet/aspire-dashboard:latest
2. Push the sample OTEL helm chart to the repository.
cd /helm-otel-chd
helm package .
helm push <helm-chart-name>-<version>.tgz oci://<ACR_NAME>.azurecr.io/<target_repo>
3. Configure helm-otel-chd/values-azure.yaml.
* 
Replace all <PLACEHOLDER> values in helm-otel-chd/values-azure.yaml.
acrToken:
enabled: true
registry: "<YOUR_ACR_NAME>.azurecr.io"
tokenName: "<TOKEN_NAME>"
tokenPassword: "<TOKEN_PASSWORD>"
secretName: "<SECRET_NAME>"

otelCollector:
image:
repository: <YOUR_ACR_NAME>.azurecr.io/otel/opentelemetry-collector-contrib

prometheus:
image:
repository: <YOUR_ACR_NAME>.azurecr.io/prom/prometheus

zipkin:
image:
repository: <YOUR_ACR_NAME>.azurecr.io/openzipkin/zipkin

aspireDashboard:
image:
repository: <YOUR_ACR_NAME>.azurecr.io/dotnet/aspire-dashboard
ACR Token
Modify the acrToken section based on your registry. If you are using ACR, use token configuration. If you are using any other container registry, update or disable acrToken and use the appropriate authentication, such as imagePullSecrets.
acrToken.registry - Azure Portal > Container Registry. Select your ACR Name.
acrToken.tokenName - Your ACR > Repository Permissions > Tokens
acrToken.tokenPassword - Previously generated ACR token password.
acrToken.secretName - Name of the Kubernetes secret object; must be different from the cb-ai-service secret name.
Repository name for components
Update each component's image.repository with your ACR login server.
4. Deploy OTEL stack using helm.
a. Get credentials for the AKS cluster.
az aks get-credentials \
--resource-group <AKS_RESOURCE_GROUP> \
--name <AKS_NAME>
b. Verify connectivity.
kubectl get namespace
c. Run the Helm install command.
helm upgrade --install <release_name> \
oci://<ACR_NAME>.azurecr.io/<PATH_TO_HELM_REPO> \
--version <VERSION> -n <NAMESPACE> --create-namespace -f values-azure.yaml
5. Configure cb-ai-service to send telemetry data.
In helm-chd/values-azure-customer-hosted.yaml file, set the following:
otel:
enableOtelCollector: true
otelExporterEndpoint: "otel-collector-chd.cbai-otel.svc.cluster.local:4317"
* 
The complete FQDN, <service>.<namespace>.svc.cluster.local, is required because cb-ai-service runs in the cb-ai-service namespace, while the OTEL stack runs in the cbai-otel namespace.
Step 2: Download the image archive zip
Download the cb-ai-chd-service-<version>.zip from PTC Software Download - Codebeamer AI.
Verify the downloaded artifact’s checksum against the published .sha256 value immediately after download and before unzipping, deploying, or executing.
Step 3: Load the docker image into Docker
Unzip the image archive and load the image in your local Docker.
docker load -i cb-ai-chd-service-<version>.tar.gz
Following is an expected output.
Loaded image: ptc-non-existing.azurecr.io/cb-ai-chd-service:<version>
Step 4: Verify the docker image
Confirm that the image is available locally.
docker images | grep cb-ai-chd-service
Following is an expected output.
ptc-non-existing.azurecr.io/cb-ai-chd-service <version> <image-id> <time> <size>
Step 5: Push the image to Container Registry
1. Tag the image for Container Registry using either of the following command.
* 
Before pushing the image to Container Registry (ACR), tag it correctly.
Use the loaded image tag as-is after docker load. Refer the output for step 4 for tag.
Commands:
docker tag ptc-non-existing.azurecr.io/cb-ai-chd-service:<tag> <ACR_NAME>.azurecr.io/<REPOSITORY_NAME>:<tag>
Or
docker tag <IMAGE_ID> <ACR_NAME>.azurecr.io/<REPOSITORY_NAME>:<tag>
Generic Container Registry.
docker tag <source-image>:<tag> <registry>/<repository>:<tag>
Example:
docker tag cb-ai-service-chd:1.1.0-r1-snapshot-b4507 mycustomeracr.azurecr.io/cb-ai-service-chd:1.1.0-r1-snapshot-b4507
2. Login to Container Registry.
For example, authenticate Docker to the ACR.
az acr login --name <ACR_NAME>
3. Push the tagged image to ACR.
For example docker push <ACR_NAME>.azurecr.io/cb-ai-service-chd:<tag>.
4. Push the helm chart to ACR or repository.
cd /helm-chd
helm package .
helm push <helm-chart-name>-<version>.tgz oci://<ACR_NAME>.azurecr.io/<target_repo>
Step 6: Configure the AI service helm chart
Move into the helm chart directory in the repository.
cd /helm-chd
Configure values-azure-customer-hosted.yaml
Update the file helm-chd/values-azure-customer-hosted.yaml.
Example configuration:
# -------------------------------
# ACR Token Configuration
# -------------------------------
acrToken:
enabled: true
registry: "<ACR_NAME>.azurecr.io"
tokenName: "<ACR_TOKEN_USERNAME>"
tokenPassword: "<ACR_TOKEN_PASSWORD>"
secretName: "<secret_name>"
# -------------------------------
# Image Configuration
# -------------------------------
image:
domain: "<ACR_NAME>.azurecr.io"
repository: "<repository_name>"
pullPolicy: IfNotPresent
tag: "<IMAGE_TAG>"
# -------------------------------
# Workload Identity
# -------------------------------
workloadIdentity:
enabled: true
clientId: "<USER_ASSIGNED_MANAGED_IDENTITY_CLIENT_ID>"
tenantId: "<AZURE_TENANT_ID>"
tokenExpirationSeconds: 3600
# -------------------------------
# Azure Configuration
# -------------------------------
azure:
openai:
baseUrl: "https://<OPENAI_RESOURCE_NAME>.openai.azure.com/"
auth:
tenantId: "<AZURE_TENANT_ID>"
audience: "<APP_REGISTRATION_CLIENT_ID>"
ACR Token
Modify acrToken section based on your registry. If using ACR, then use token configuration. If using other container registry update or disable acrToken and use appropriate authentication. For example, imagePullSecrets.
acrToken.registry - Azure Portal > Container Registry > Select your ACR name
acrToken.tokenName - Your ACR > Repository Permissions > Tokens
acrToken.tokenPassword - Previously generated ACR Token password
acrToken.secretName - Name for Kubernetes secret object
Image Configuration
image.domain - Azure Portal > Container Registry > Select your ACR name.
image.repository - Must match with step 5 repository name for your ACR.
image.tag - Must match with step 5 image tag for your ACR.
Workload Identity
workloadIdentity.clientId -
From Azure > Resource Group CHD > Managed Identity - Client ID
Or
From Terraform output of infrastructure deployment: managed_identity_client_id.
workloadIdentity.tenantId - Your Azure tenant ID.
Azure
azure.openai.baseUrl
Azure > Resource Group CHD > Cognitive Account.
Or
From Terraform output of infrastructure deployment: openai_endpoint.
azure.auth.tenantId - Your Azure tenant ID.
azure.auth.audience - Terraform output audience.
Step 7: Deploy cb-ai-service using helm
Get AKS credentials
Connect your local machine to the AKS cluster.
az aks get-credentials \
--resource-group <AKS_RESOURCE_GROUP> \
--name <AKS_NAME>
Verify connectivity.
kubectl get namespace
Run the helm install command
helm upgrade --install cb-ai-service \
oci://<ACR_NAME>.azurecr.io/<PATH_TO_HELM_REPO> \
--version <VERSION> -n <NAMESPACE> -f values-azure-customer-hosted.yaml
Verify deployment
Check pod status.
kubectl get pods -n cb-ai-service
All pods should be in running state.
Check application logs
kubectl logs <pod_name> -n cb-ai-service
Generate JWT for functional verification
Perform the following steps to generate a JWT token and validate the deployed cb-ai-service.
1. Create client secret.
Run the following command and store the returned client secret (password) securely, which is required for generating JWT:
az ad app credential reset \
--id <client_id> \
--display-name "cbai-client-secret" \
--years 1
2. Use one of the following option to generate the JWT token.
Run the python script.
a. Save the following code with the filename get_customer_hosted_token.py. Update the placeholders with your values.
#!/usr/bin/env python3
"""Fetch an Azure Entra ID access token via client credentials.

All identity parameters (client-id, client-secret, tenant-id, scope) must be
supplied by the caller – either as CLI flags or via environment variables.

Usage example (prefers environment variables to keep secrets out of shell history):

export CB_CUSTOMER_HOSTED_CLIENT_ID="<client id>"
export CB_CUSTOMER_HOSTED_CLIENT_SECRET="<client-secret>"
export CB_CUSTOMER_HOSTED_TENANT_ID="<tenant id>"
export CB_CUSTOMER_HOSTED_SCOPE="api://<app-id>/.default"
python scripts/get_customer_hosted_token.py

Pass --json to inspect the raw Azure response.
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from typing import Any

import httpx

DEFAULT_TOKEN_URL_TEMPLATE = (
"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
)


class TokenRetrievalError(RuntimeError):
"""Raised when MS Entra ID returns an error response."""


def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Retrieve an Azure Entra ID access token with client credentials",
)
parser.add_argument(
"--client-id",
default=os.environ.get("CB_CUSTOMER_HOSTED_CLIENT_ID"),
help="Application (client) ID. (env: CB_CUSTOMER_HOSTED_CLIENT_ID)",
)
parser.add_argument(
"--client-secret",
default=os.environ.get("CB_CUSTOMER_HOSTED_CLIENT_SECRET"),
help="Client secret (env: CB_CUSTOMER_HOSTED_CLIENT_SECRET).",
)
parser.add_argument(
"--tenant-id",
default=os.environ.get("CB_CUSTOMER_HOSTED_TENANT_ID"),
help="Directory (tenant) ID. (env: CB_CUSTOMER_HOSTED_TENANT_ID)",
)
parser.add_argument(
"--scope",
default=os.environ.get("CB_CUSTOMER_HOSTED_SCOPE"),
help="Target scope, e.g. api://<app-id>/.default (env: CB_CUSTOMER_HOSTED_SCOPE).",
)
parser.add_argument(
"--timeout",
type=float,
default=float(os.environ.get("CB_CUSTOMER_HOSTED_TIMEOUT", 10)),
help="HTTP timeout in seconds (default: 10).",
)
parser.add_argument(
"--json",
action="store_true",
help="Print raw JSON response instead of just the access token.",
)
return parser


def _build_token_url(tenant_id: str) -> str:
return DEFAULT_TOKEN_URL_TEMPLATE.format(tenant_id=tenant_id)


def fetch_token(
*, client_id: str, client_secret: str, scope: str, tenant_id: str, timeout: float
) -> dict[str, Any]:
if not client_secret:
raise ValueError(
"Client secret is required. Provide --client-secret or CB_CUSTOMER_HOSTED_CLIENT_SECRET."
)

token_url = _build_token_url(tenant_id)
data = {
"client_id": client_id,
"client_secret": client_secret,
"scope": scope,
"grant_type": "client_credentials",
}
with httpx.Client(timeout=timeout) as client:
response = client.post(token_url, data=data)
if response.is_error:
raise TokenRetrievalError(
f"Token request failed ({response.status_code}): {response.text.strip()}"
)
return response.json()


def get_chd_auth_header(
*,
client_id: str,
client_secret: str,
scope: str,
tenant_id: str,
timeout: float = 10.0,
) -> dict[str, str]:
token_scope = scope if scope.endswith("/.default") else f"{scope}/.default"
token_response = fetch_token(
client_id=client_id,
client_secret=client_secret,
scope=token_scope,
tenant_id=tenant_id,
timeout=timeout,
)
access_token = token_response.get("access_token")
if not access_token:
raise TokenRetrievalError("Token response did not contain 'access_token'")
return {"Authorization": f"Bearer {access_token}"}


def main() -> int:
parser = _build_parser()
args = parser.parse_args()

missing = []
if not args.client_id:
missing.append("--client-id / CB_CUSTOMER_HOSTED_CLIENT_ID")
if not args.client_secret:
missing.append("--client-secret / CB_CUSTOMER_HOSTED_CLIENT_SECRET")
if not args.tenant_id:
missing.append("--tenant-id / CB_CUSTOMER_HOSTED_TENANT_ID")
if not args.scope:
missing.append("--scope / CB_CUSTOMER_HOSTED_SCOPE")
if missing:
print(
f"Error: the following required parameters are missing:\n "
+ "\n ".join(missing),
file=sys.stderr,
)
return 1

try:
token_response = fetch_token(
client_id=args.client_id,
client_secret=args.client_secret,
scope=args.scope,
tenant_id=args.tenant_id,
timeout=args.timeout,
)
except (TokenRetrievalError, ValueError, httpx.HTTPError) as error:
print(f"Error: {error}", file=sys.stderr)
return 1

if args.json:
print(json.dumps(token_response, indent=2))
else:
access_token = token_response.get("access_token")
if not access_token:
print(json.dumps(token_response, indent=2))
else:
print(access_token)
return 0


if __name__ == "__main__": # pragma: no cover
sys.exit(main())
b. Execute the following command:
python get_customer_hosted_token.py \
--client-id "<client_id>" \
--client-secret "<client_secret>" \
--tenant-id "<tenant_id>" \
--scope "api://<client_id>/.default" \
--json
Parameter
Description
--client-id
Application (client) ID from Azure App Registration.
--client-secret
Client secret created earlier.
--tenant-id
Azure AD tenant ID.
--scope
API scope. This must match audience configured in helm.
--json
Output token in JSON format.
Run the following curl command.
curl --location 'https://login.microsoftonline.com/<TENANT_ID>/oauth2/v2.0/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=<CLIENT_ID>' \
--data-urlencode 'client_secret=<CLIENT_SECRET>' \
--data-urlencode 'scope=<AUDIENCE>/.default' \
--data-urlencode 'grant_type=client_credentials'
3. Test JWT token.
Check the service IP address, obtain the JWT token from the above command, and run the following curl command:
curl --location 'http://<SERVICE_IP>/cb-ai-service/requirement-evaluation/v1' --header 'Accept-Language: string' --header 'Content-Type: application/json' --header 'Accept: application/json' --header 'Authorization: Bearer <TOKEN>' --data '{
"requirement": {
"summary": "Advanced Navigation",
"description": {
"value": "Die Batteriesoll dem Bordcomputer dem Autos eine Spannung von fast 28 VDC liefern.",
"valueType": "TEXT"
}
},
"standards": [
"INCOSE_RULES_4_0"
],
"ruleFilters": {
"INCOSE_RULES_4_0": {
"en": "R34,R18-R34,R12",
"de": "R7,R33,R27"
}
}
}'
* 
It is recommended that for new deployments, start with Pay-As-You-Go SKUs, DataZoneStandard or GlobalStandard, and switch to PTU in a subsequent Terraform apply after the deployment is stable.
PTU model provisioning are slower compared to Pay-As-You-Go deployments. When Terraform creates the Cognitive Account, private endpoint, private DNS zone, and PTU deployment in one apply, the PTU deployment takes 5-20+ minutes to provision on Azure even after Terraform reports success. During this time, the private endpoint DNS is waiting to be fully propagated for the PTU deployment, causing API calls from cb-ai-service to fail.
Error in cb-ai-service pod logs or curl:
{"event": "Error code: 403 - {'error': {'code': '403', 'message': 'Traffic is not from an approved private endpoint.'}}", "dd.trace_id": "ecfb4368512eba7e0b2a0aa20caf24a3", "dd.span_id": "563883118223141984", "timestamp": "2026-04-06 04:16:45", "level": "error", "logger": "cbai.cb_ai_service.exception_handlers"}
This is a timing issue. The PTU deployment's private link integration is still propagating while requests are sent. Most of the time the error resolves itself after 45-60 minutes, depending on Azure provisioning time.
Was this helpful?