MTE Relay Server on Azure
Introduction
MTE Relay Server is a proxy that runs in front of your backend. Client applications use an MTE Relay client library to send encrypted requests; the relay decrypts each one, forwards the plaintext request to your backend, and encrypts the response on the way back.

MTE Relay Server pairs only with the MTE Relay client libraries. It cannot pair with an MTE API Relay. v5 relays serve only v5 clients; see Upgrading from v4.
On Azure, MTE Relay Server is a Kubernetes app on Azure Marketplace. You install it with a six tab wizard in the Azure portal, and the wizard does the rest: it creates an Azure Kubernetes Service (AKS) cluster or uses one you have, installs the relay as a cluster extension, and stores your backend's address as the relay's first configuration. When the deployment finishes, the relay is already forwarding requests. One namespace holds everything:
- The relay pods, scaled by a horizontal pod autoscaler on CPU.
- The control plane, the web panel that configures every relay pod, with its settings on an Azure Disk.
- Optionally, one Valkey pod that holds the pair state the relay pods share.
The offer bills through your Azure subscription at the plan's hourly price for every running relay pod and for the control plane pod, so the subscription is the license. The price is on the offer's Plans + Pricing tab. A pod bills from the moment it runs, ready or not, so one relay pod with its control plane bills two pods, and the default range of two to six relay pods bills three to seven.
For MTE Relay v4 on Azure, see the MTE Relay v4 Azure guide.
Before you start
- An Azure subscription that can buy Marketplace software. Pay as you go and Enterprise Agreement subscriptions can. Free credits and some sponsorship subscriptions cannot.
- Your backend's address, such as
https://api.example.com. The wizard asks for it, and the relay must be able to reach it from the cluster. - The origins of any web apps that will call the relay from a browser,
such as
https://app.example.com. Mobile and server clients need none. - An admin password for the control plane, 16 characters or more, kept in your password manager.
- An AKS cluster, or none. The wizard can create one. An existing cluster
needs Kubernetes 1.30 or later, a managed identity, and Linux
amd64nodes. - A shell for the checks after the install. Azure Cloud Shell (Bash) in
the portal already has
azandkubectl. On your own machine, install the Azure CLI andkubectl. To open the control plane in a browser, you needkubectlon a machine that also has the browser.
If the cluster restricts egress, allow outbound HTTPS to
<region>.dp.kubernetesconfiguration.azure.com, mcr.microsoft.com,
*.data.mcr.microsoft.com, arcmktplaceprod.azurecr.io and its regional
*.data.azurecr.io endpoints, *.ingestion.msftcloudes.com, and
*.microsoftmetrics.com, plus your backend and, with Azure Managed Redis,
port 10000 to the cache.
Install from Azure Marketplace
In the Azure portal, open Marketplace, search for MTE Relay Server, open the Eclypses offer, pick the plan, and choose Create. From an existing cluster you can also open Extensions + applications, choose Add, and pick the offer.
The wizard has six tabs. Each one is below with its defaults; most installs change only the fields marked here as required.
1. Basics

| Field | What to enter |
|---|---|
| Subscription | The subscription that pays for the plan. |
| Resource group | For an existing cluster, the cluster's own resource group: the next tab lists only the clusters in it. For a new cluster, any resource group, or Create new. |
| Region | Where a new cluster goes. An existing cluster keeps its own region, so this field does nothing for it. |
| Create a new AKS cluster? | No installs into a cluster you already have. Yes creates one first, with the settings on the next tab. |
2. Cluster
With an existing cluster, pick it from AKS cluster. If the tab says the resource group has no AKS cluster, go back to Basics and choose the resource group the cluster lives in.
With a new cluster, the tab shows its settings:

| Field | What to choose |
|---|---|
| Cluster name | Defaults to mte-relay. 2 to 54 letters, numbers, and hyphens. It is also the cluster's DNS prefix. |
| Kubernetes version | Required. The list holds the generally available versions in the region, newest first. Pick the first one. |
| Pricing tier | Standard, the default, carries Microsoft's uptime SLA for the Kubernetes API server. Free has none; use it only to evaluate. |
| Spread nodes across availability zones 1, 2 and 3 | On by default. Keeps the relay running through a zone outage. Turn it off only in a region without zones; it cannot change later. |
| Node operating system | Azure Linux by default, or Ubuntu. |
| System node size | Defaults to Standard_D2s_v5. Two system nodes run the cluster's own add ons. A taint keeps the relay off them. |
| Relay node size | Defaults to Standard_D2als_v6. The relay pool runs the relay pods, the control plane, and Valkey. A 2 vCPU, 4 GiB node fits one medium relay pod. The large pod size needs 4 vCPU nodes. |
You do not pick a node count. The relay pool autoscales between two nodes, or
one for a single relay pod, and one node more than the relay pod maximum on
the next tab: two to seven nodes with the defaults. The new cluster also gets
Azure CNI Overlay with Cilium network policy, workload identity, and the
stable automatic upgrade channel with NodeImage node updates. Creating a
cluster adds about five minutes to the install.
3. Relay
The Relay tab has three sections. Only Backend API address is required.
Backend

| Field | What to enter |
|---|---|
| Backend API address | Required. Where the relay forwards decrypted requests, such as https://api.example.com. Use http:// only for a backend on a private network. The address may carry a path but no commas or spaces. |
| Allowed browser origins | The origins of the web apps that call the relay from a browser, separated by commas, such as https://app.example.com. *.example.com or https://*.example.com allows every subdomain. Leave it empty for mobile and server clients. |
| Extension name | Defaults to mte-relay-mrs. The extension's name on the cluster. Each install in a cluster needs its own. |
| Namespace | Defaults to mte-relay-mrs. Use a new namespace; one install per namespace. The commands on this page assume the default. |
The address becomes a single route that matches every host name: whatever host a client calls, the relay forwards to this backend. Add host specific routes later in the control plane, under Proxies.
A lone * in Allowed browser origins lets every website call the relay
from its visitors' browsers, with their cookies. The tab warns when you enter
it. List your app origins instead.
Network

| Field | What to choose |
|---|---|
| Expose the relay through | Public load balancer, the default, gives the relay a public IP address. Internal load balancer gives it a private address in the cluster's virtual network. Cluster only gives it no address outside the cluster, for Application Gateway for Containers or your own ingress. |
| Load balancer port | Defaults to 80. The port clients connect to. The relay listens on 8080 inside the pod. |
| Allowed client address ranges | Empty by default, which admits every address. IPv4 CIDRs the load balancer admits, separated by commas, such as 203.0.113.0/24. |
| DNS name label | Public load balancer only. Names the address <label>.<region>.cloudapp.azure.com. The label must be unique in the region. |
Both load balancers carry plain HTTP, and the tab warns about it. MTE encrypts every request and response body, but URLs and headers travel in the clear. A public load balancer is the quick way to try the relay. For production, choose Cluster only and put Application Gateway for Containers with TLS in front, as in Production setup. You can switch later without reinstalling.
Scaling

| Field | What to choose |
|---|---|
| Shared pair state | None runs exactly one relay pod. Valkey in the cluster, the default, runs one Valkey pod, which is not billed, so two or more relay pods can serve any client. Existing Redis uses a cache you run, such as Azure Managed Redis. |
| Scale relay pods on CPU | On by default. A horizontal pod autoscaler adds a relay pod when average CPU passes the target, and removes one a minute after five quiet minutes. Off runs the minimum. |
| Relay pods, minimum | Defaults to 2. The autoscaler's floor, 1 to 20. Two or more keep the relay up during updates and node restarts. |
| Relay pods, maximum | Defaults to 6. The autoscaler's ceiling, 1 to 50, and so the most relay pods billed in an hour. |
| Target average CPU | Defaults to 80. Percent of each pod's CPU request, 30 to 90. |
| Relay pod size | Medium by default. Small requests 0.5 vCPU and 512 MiB, Medium 1 vCPU and 1 GiB, Large 2 vCPU and 2 GiB; each may use twice its request. A new cluster with 2 vCPU relay nodes offers Small and Medium only. |
With None, the pod fields disappear: the relay runs one pod. Valkey keeps pair state in memory, so if its pod restarts, clients pair again on their next request. For a zone redundant store, choose Existing Redis:

The cache needs the Enterprise or Non clustered clustering policy,
never OSS, access key authentication, and a network path from the
cluster. Azure Managed Redis below creates one. Port
10000 and Connect with TLS are Azure Managed Redis's own settings.
4. Control plane

| Field | What to choose |
|---|---|
| Admin password | Required, 16 to 128 characters. You sign in to the panel as admin with it. |
| Reach the control plane through | kubectl port-forward only, the default, opens nothing: administrators reach the panel through kubectl. Internal load balancer gives it a private address on port 8081 in the cluster's virtual network, for administrators on a VPN or a peered network. It serves plain HTTP, so use it only on a network you trust. The panel is never public. |
| Allowed administrator address ranges | Empty by default, which admits the whole virtual network. Internal load balancer only. Private IPv4 CIDRs, separated by commas, such as your VPN's client range. |
| Settings disk | 1 GiB by default, or 4 GiB. The Azure Disk that holds the panel's settings and the last 50 versions of the configuration. A few megabytes are used. It can grow later, not shrink. |
| Expose Prometheus metrics on port 9090 | Off by default. Relay pods and the panel serve /metrics on port 9090 inside the cluster, behind a bearer token in the Secret mte-relay-metrics. |
5. Updates and tags

| Field | What to choose |
|---|---|
| Install new minor and patch versions automatically | On by default. Azure upgrades the extension to new 5.x releases, security fixes included. Major versions always wait for you. Turn it off to stay on this version until you upgrade with az k8s-extension update. |
| Tags | Shown only for a new cluster, and applied to it. Extensions take no tags. |
6. Review + create
The portal checks every tab, then shows your choices, the plan's price, and its terms. Choose Create.
The deployment page lists the cluster, if you asked for one, and then the extension. Azure waits until the relay pods are ready before it marks the extension Succeeded, so a finished deployment is a serving relay. If the deployment fails, see Troubleshooting.
After the install
Check the relay
In the portal, open the cluster, then Kubernetes resources >
Services and ingresses, and filter to the mte-relay-mrs namespace. The
mte-relay service's External IP is the relay's address.
Or, from Azure Cloud Shell:
export RG=mte-relay # Update this value! The cluster's resource group
export CLUSTER=mte-relay # Update this value! The cluster's name
az aks get-credentials --resource-group $RG --name $CLUSTER
kubectl -n mte-relay-mrs get pods
RELAY=$(kubectl -n mte-relay-mrs get svc mte-relay -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
curl http://$RELAY/api/mte-echo
curl "http://$RELAY/api/mte-relay?frameVersions=2"
Every pod is 1/1 Running, the echo answers {"message":"true",...}, and the
discovery call returns a client id with "buildVersion":"5.0.5". On
an internal load balancer, run the same commands from a machine in the virtual
network. With Cluster only, forward the relay's port and call
localhost:8080 instead:
kubectl -n mte-relay-mrs port-forward svc/mte-relay 8080:8080
Connect your app
Point the client library at the relay's address, http://<external IP> while
you test, or your own https:// host name once the relay sits behind
Application Gateway for Containers:
A browser app needs its origin under Allowed browser origins, or under Allowed origins for the host in the control plane, or its requests fail with a CORS error.
Open the control plane
You do not have to open the panel to start serving. Open it to add host names, change the backend, set CORS, or tune limits.
On a machine with kubectl and a browser, after az aks get-credentials,
start a port forward. It keeps running, so use a second terminal:
kubectl -n mte-relay-mrs port-forward svc/mte-relay-control-plane 8081:8081
Open http://localhost:8081 and sign in as admin
with the password from the Control plane tab. To read it back from the
cluster:
kubectl -n mte-relay-mrs get secret mte-relay-control-plane -o jsonpath='{.data.ADMIN_PW}' | base64 -d; echo
The panel opens on the dashboard with your first configuration in place. There is no setup wizard to run; that is the step the Marketplace form replaces.
- Proxies holds one host,
*, that sends every host name to your backend, with the browser origins you entered and a client secret the panel generated. Add a host such asrelay.example.comto route it somewhere else, or to give it its own CORS settings. COMPANY_NAMEandLICENSE_KEYunder Configuration stay empty. The subscription is the license.- Configuration holds every other setting. Behind Application Gateway for
Containers, set
TRUSTED_PROXIESthere, as in step 6. - Fleet lists each relay pod by name, with runtime
kubernetes.
The forward is tied to one pod. When the control plane pod is replaced, it
stops with lost connection to pod; run the same command again. The
control plane guide
covers every page of the panel.
Production setup
The wizard's defaults are a complete deployment. For production, keep its shape and add these:
- Standard tier and availability zones on the cluster, with the relay node pool in every zone and the cluster autoscaler on. A new cluster from the wizard has all three.
- Two or more relay pods with autoscaling, and a maximum you are willing to pay for.
- Azure Managed Redis with a private endpoint, as in Azure Managed Redis.
- Application Gateway for Containers with a publicly trusted certificate,
the web application firewall in Prevention mode, and
TRUSTED_PROXIESset to its subnet, as in Application Gateway for Containers. The relay's Service staysClusterIP. - The control plane reachable only privately: by
kubectl port-forward, or by the internal load balancer from a VPN or a peered network. Never route port 8081 through the gateway. - A copy of the configuration outside the cluster: export it as in Backup and restore, or back the namespace up with Azure Backup for AKS.
- Monitoring: Container insights for pod logs and node metrics, with alerts on the relay Deployment's available replicas. The relay writes JSON logs; see Monitoring and Integrations.
The rest of this section runs from bash, such as Azure Cloud Shell, with RG
and CLUSTER set as in Check the relay.
Azure Managed Redis
Valkey in the cluster is one pod: if it restarts, clients pair again, and
pairing requests in those seconds answer 503 state_store_unavailable. For a
zone redundant store, use Azure Managed Redis. Microsoft is retiring Azure
Cache for Redis and no longer creates new caches in the global Azure cloud.
The relay uses a single endpoint Redis client, so the cache needs the Enterprise or Non clustered clustering policy, never OSS, and access key authentication. Create it, give it a private endpoint in the cluster's virtual network, then turn public access off:
export LOCATION=westus3 # Update this value! The cluster's region
export AMR=mte-relay-redis-$RANDOM # Cache names are global
az extension add --name redisenterprise --upgrade
az redisenterprise create --resource-group $RG --name $AMR --location $LOCATION \
--sku Balanced_B1 --clustering-policy EnterpriseCluster --access-keys-auth Enabled \
--public-network-access Enabled
MC=$(az aks show -g $RG -n $CLUSTER --query nodeResourceGroup -o tsv)
VNET=$(az network vnet list -g $MC --query '[0].name' -o tsv)
az network vnet subnet create -g $MC --vnet-name $VNET -n snet-private-endpoints \
--address-prefixes 10.226.0.0/24
az network private-endpoint create -g $RG -n $AMR-pe --location $LOCATION \
--subnet $(az network vnet subnet show -g $MC --vnet-name $VNET -n snet-private-endpoints --query id -o tsv) \
--private-connection-resource-id $(az redisenterprise show -g $RG -n $AMR --query id -o tsv) \
--group-id redisEnterprise --connection-name $AMR-pe
az network private-dns zone create -g $RG -n privatelink.redis.azure.net
az network private-dns link vnet create -g $RG -z privatelink.redis.azure.net -n aks-vnet \
-v $(az network vnet show -g $MC -n $VNET --query id -o tsv) -e false
az network private-endpoint dns-zone-group create -g $RG --endpoint-name $AMR-pe -n default \
--private-dns-zone privatelink.redis.azure.net --zone-name redis
az redisenterprise update -g $RG -n $AMR --public-network-access Disabled
- The commands assume the virtual network AKS created,
10.224.0.0/12. In your own virtual network, put the private endpoint in any subnet and link the zone to that network. Balanced_B1holds 1 GB. Pair state is small: each paired client keeps a few kilobytes per pair until it goes idle.
Point the extension at the cache. A protected setting you leave out keeps its stored value, so send only the access key:
KEY=$(az redisenterprise database list-keys -g $RG --cluster-name $AMR --query primaryKey -o tsv)
az k8s-extension update --name mte-relay-mrs --cluster-name $CLUSTER --resource-group $RG \
--cluster-type managedClusters --yes \
--config redis.mode=external redis.external.host=$AMR.$LOCATION.redis.azure.net \
redis.external.port=10000 redis.external.tls=true \
--config-protected redis.external.passwordBase64=$(printf '%s' "$KEY" | base64 -w0)
kubectl -n mte-relay-mrs rollout status deployment/mte-relay
kubectl -n mte-relay-mrs logs deployment/mte-relay | grep "Redis state store initialized"
The extension removes the Valkey pod, and each relay pod logs
Redis state store initialized when it reaches the cache. To use the cache
from the start, create it first and pick Existing Redis on the
Relay tab.
Application Gateway for Containers
The relay serves plain HTTP. For production, put Application Gateway for Containers in front of it: Azure's layer 7 load balancer for AKS, with TLS and Azure Web Application Firewall. These steps install its controller with Helm, the generally available path, and let the controller create the gateway.
Before you start:
- The cluster must use Azure CNI, which includes Azure CNI Overlay, and be in a region where Application Gateway for Containers is available. A cluster the wizard created qualifies.
- You need a certificate for the hostname clients call, from a public certificate authority, in PEM files. The gateway reads it from a Kubernetes Secret: renew it by replacing the Secret, or have cert-manager manage it.
- The commands use
helm, which Azure Cloud Shell has.
export LOCATION=westus3 # Update this value!
export RELAY_HOST=relay.example.com # Update this value! The hostname clients call
export SUBNET_PREFIX=10.225.0.0/24 # Update this value! A free /24 in the cluster's virtual network
SUBNET_PREFIX must sit inside the cluster's virtual network and overlap no
other subnet. The virtual network AKS creates is 10.224.0.0/12 with nodes
in 10.224.0.0/16, so the example fits it.
1. Register the providers and enable workload identity
for p in Microsoft.ContainerService Microsoft.Network Microsoft.NetworkFunction Microsoft.ServiceNetworking; do
az provider register --namespace $p --wait
done
az aks update -g $RG -n $CLUSTER --enable-oidc-issuer --enable-workload-identity
A cluster the wizard created already has both; the update returns at once.
2. Create the controller's identity
MC=$(az aks show -g $RG -n $CLUSTER --query nodeResourceGroup -o tsv)
MC_ID=$(az group show -n $MC --query id -o tsv)
az identity create -g $RG -n azure-alb-identity
sleep 60
PRINCIPAL=$(az identity show -g $RG -n azure-alb-identity --query principalId -o tsv)
az role assignment create --assignee-object-id $PRINCIPAL --assignee-principal-type ServicePrincipal \
--scope $MC_ID --role acdd72a7-3385-48ef-bd42-f606fba81ae7
az role assignment create --assignee-object-id $PRINCIPAL --assignee-principal-type ServicePrincipal \
--scope $MC_ID --role fbc52c3f-28ad-4303-a892-8a056630b8f1
az identity federated-credential create --name azure-alb-identity --identity-name azure-alb-identity \
--resource-group $RG --issuer $(az aks show -g $RG -n $CLUSTER --query oidcIssuerProfile.issuerUrl -o tsv) \
--subject system:serviceaccount:azure-alb-system:alb-controller-sa
The roles are Reader and AppGw for Containers Configuration Manager on the cluster's node resource group. The pause lets the new identity replicate before the role assignments name it.
3. Install the controller
helm install alb-controller oci://mcr.microsoft.com/application-lb/charts/alb-controller \
--namespace azure-alb-system --create-namespace --version 1.12.2 \
--set albController.namespace=azure-alb-system \
--set albController.podIdentity.clientID=$(az identity show -g $RG -n azure-alb-identity --query clientId -o tsv)
kubectl -n azure-alb-system rollout status deployment/alb-controller
kubectl get gatewayclass azure-alb-external
The gateway class reports ACCEPTED True.
4. Create the gateway's subnet
VNET=$(az network vnet list -g $MC --query '[0].name' -o tsv)
az network vnet subnet create -g $MC --vnet-name $VNET -n subnet-alb \
--address-prefixes $SUBNET_PREFIX --delegations Microsoft.ServiceNetworking/trafficControllers
ALB_SUBNET_ID=$(az network vnet subnet show -g $MC --vnet-name $VNET -n subnet-alb --query id -o tsv)
az role assignment create --assignee-object-id $PRINCIPAL --assignee-principal-type ServicePrincipal \
--scope $ALB_SUBNET_ID --role 4d97b98b-1d4f-4787-a291-c67834d212e7
In your own virtual network, run the same commands with its resource group
and name in place of $MC and $VNET. The role is Network Contributor on the
subnet, which lets the controller join it.
5. Switch the relay to Cluster only and route to it
If you installed with a load balancer, switch the relay's Service to
ClusterIP:
az k8s-extension update --name mte-relay-mrs --cluster-name $CLUSTER --resource-group $RG \
--cluster-type managedClusters --yes \
--config service.exposure=cluster
Store your certificate and key for $RELAY_HOST, and create the gateway:
kubectl -n mte-relay-mrs create secret tls mte-relay-tls --cert=relay.crt --key=relay.key
kubectl apply -f - <<EOF
apiVersion: alb.networking.azure.io/v1
kind: ApplicationLoadBalancer
metadata:
name: mte-relay
namespace: mte-relay-mrs
spec:
associations:
- $ALB_SUBNET_ID
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: mte-relay
namespace: mte-relay-mrs
annotations:
alb.networking.azure.io/alb-namespace: mte-relay-mrs
alb.networking.azure.io/alb-name: mte-relay
spec:
gatewayClassName: azure-alb-external
listeners:
- name: https
port: 443
protocol: HTTPS
allowedRoutes:
namespaces:
from: Same
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: mte-relay-tls
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: mte-relay
namespace: mte-relay-mrs
spec:
parentRefs:
- name: mte-relay
rules:
- backendRefs:
- name: mte-relay
port: 8080
EOF
kubectl -n mte-relay-mrs wait --for=condition=Programmed gateway/mte-relay --timeout=15m
FQDN=$(kubectl -n mte-relay-mrs get gateway mte-relay -o jsonpath='{.status.addresses[0].value}')
echo $FQDN
- Azure creates the gateway in about six minutes.
$FQDNends in.alb.azure.com; point a CNAME record for$RELAY_HOSTat it. - The gateway sends traffic only to ready relay pods: it takes its health
check from the relay's readiness probe,
GET /readyzon port 9091. - The listener has no hostname, so every name reaches the relay, and the relay
routes by
Host.
6. Trust the gateway's forwarded addresses
The relay sees the gateway's addresses, not the clients'. In the panel, open
Configuration and set TRUSTED_PROXIES to the subnet's CIDR, the value of
$SUBNET_PREFIX (10.225.0.0/24 in the example). Save, then restart the
relay pods so each client gets its own rate limit budget again:
kubectl -n mte-relay-mrs rollout restart deployment/mte-relay
7. Attach the web application firewall
az network application-gateway waf-policy create -g $RG -n mte-relay-waf -l $LOCATION \
--type Microsoft_DefaultRuleSet --version 2.1
az network application-gateway waf-policy policy-setting update -g $RG --policy-name mte-relay-waf \
--mode Prevention --state Enabled --request-body-check false
WAF_ID=$(az network application-gateway waf-policy show -g $RG -n mte-relay-waf --query id -o tsv)
az role assignment create --assignee-object-id $PRINCIPAL --assignee-principal-type ServicePrincipal \
--scope $WAF_ID --role 4d97b98b-1d4f-4787-a291-c67834d212e7
kubectl apply -f - <<EOF
apiVersion: alb.networking.azure.io/v1
kind: WebApplicationFirewallPolicy
metadata:
name: mte-relay
namespace: mte-relay-mrs
spec:
targetRef:
group: gateway.networking.k8s.io
kind: Gateway
name: mte-relay
namespace: mte-relay-mrs
webApplicationFirewall:
id: $WAF_ID
EOF
kubectl -n mte-relay-mrs wait --for=condition=Programmed webapplicationfirewallpolicy/mte-relay --timeout=5m
- Application Gateway for Containers supports the Default Rule Set 2.1.
--request-body-check falseis required. Relay bodies are MTE ciphertext or key material: with body inspection on, the rule set blocks every pairing request,POST /api/mte-pair, and browsers report the403as a CORS error. The firewall still inspects URLs, query strings, and headers.- The controller's identity needs Network Contributor on the policy to join
it. Without it the policy stays unprogrammed with
LinkedAuthorizationFailed.
8. Check it
curl https://$RELAY_HOST/api/mte-echo
curl -s -o /dev/null -w '%{http_code}\n' "https://$RELAY_HOST/api/mte-echo?q=/etc/passwd"
The echo answers through TLS, and the second request gets 403 from the
firewall. Before DNS is in place, add --connect-to $RELAY_HOST:443:$FQDN:443
to both commands.
Change settings later
Every field on the wizard's tabs is an extension setting, listed in
Settings reference. Change one with az k8s-extension update;
it sends only the keys you name:
az k8s-extension update --name mte-relay-mrs --cluster-name $CLUSTER --resource-group $RG \
--cluster-type managedClusters --yes \
--config relay.autoscaling.minReplicas=3 relay.autoscaling.maxReplicas=10
-
Never
kubectl scaleorkubectl editthe relay. The extension reapplies its own values on every update and automatic upgrade. -
The backend address and browser origins from the Relay tab seed the first configuration only. After the install, change them in the panel under Proxies; updating
controlPlane.initialConfig.*has no effect. -
Protected settings, the admin password and the Redis access key, travel base64 encoded with
--config-protected. A protected setting you leave out keeps its stored value. -
The node range lives on the node pool. A cluster the wizard created names it
relay:az aks nodepool update --resource-group $RG --cluster-name $CLUSTER --name relay \--update-cluster-autoscaler --min-count 2 --max-count 8On your own cluster, turn the autoscaler on for the pool the relay runs in with
--enable-cluster-autoscalerinstead. -
Upgrades: with automatic upgrades on, Azure installs new 5.x versions. To pin a version, run
az k8s-extension update ... --yes --auto-upgrade-mode none, then move with--version. -
Restart fields: some panel settings apply at the next restart; the panel says which. Run
kubectl -n mte-relay-mrs rollout restart deployment/mte-relay. -
Updates keep serving: relay pods roll one at a time, each new pod joins only when ready, and each old one drains for up to 30 seconds after the load balancer stops sending it traffic.
-
The control plane is down: relay pods keep serving their last configuration. A relay pod that starts during the outage stays
0/1until the panel answers. -
Uninstall: in the portal, open the cluster's Extensions + applications, select the extension, and choose Uninstall, or run
az k8s-extension delete. Billing stops with it. Uninstalling removes what the extension created, including the panel's disk, so export the configuration first if you may reinstall.
In Eclypses' test of this chart on AKS, load generators held about 17,600
requests a second against the echo endpoint for six minutes. The autoscaler
grew the relay from two pods to its maximum of six, the cluster autoscaler
added a node within 90 seconds, and a settings update replaced every pod
during the run. All 6.3 million requests answered 200.
Settings reference
| Key | Wizard field | Default | Meaning |
|---|---|---|---|
controlPlane.initialConfig.upstream | Backend API address | empty | The first configuration's backend. Required for an install to finish. |
controlPlane.initialConfig.corsOrigins | Allowed browser origins | empty | Origins separated by spaces, since settings values cannot hold commas. |
service.exposure | Expose the relay through | public | public, internal, or cluster. |
service.port | Load balancer port | 80 | The load balancer's port. |
service.allowedSourceRanges | Allowed client address ranges | empty | CIDRs separated by spaces. |
service.dnsLabel | DNS name label | empty | Public DNS name label. |
service.publicIpName, service.publicIpResourceGroup | none | empty | An existing Standard public IP for the load balancer. |
service.internalSubnet | none | empty | The subnet for an internal load balancer. |
service.idleTimeoutMinutes | none | 30 | The load balancer's TCP idle timeout, 4 to 100. |
redis.mode | Shared pair state | none | none, inCluster, or external. |
redis.external.host, redis.external.port, redis.external.tls | Redis host name, port, Connect with TLS | empty, 10000, true | An external Redis. |
relay.autoscaling.enabled | Scale relay pods on CPU | false | Turns the horizontal pod autoscaler on. |
relay.replicas | Relay pods, minimum | 1 | Relay pods when autoscaling is off. |
relay.autoscaling.minReplicas, relay.autoscaling.maxReplicas | Relay pods, minimum and maximum | 2, 6 | The autoscaler's range. |
relay.autoscaling.targetCPUUtilizationPercentage | Target average CPU | 80 | The CPU target. |
relay.resources.requests.cpu, .memory, relay.resources.limits.cpu, .memory | Relay pod size | 1, 1Gi, 2, 2Gi | Each relay pod's CPU and memory. Set relay.goMemLimit to about 85 percent of the memory limit. |
controlPlane.access | Reach the control plane through | portForward | portForward or internal. |
controlPlane.allowedSourceRanges | Allowed administrator address ranges | empty | CIDRs separated by spaces. |
controlPlane.internalSubnet | none | empty | The subnet for the panel's internal load balancer. |
controlPlane.storage.size, controlPlane.storage.storageClass | Settings disk | 1Gi, managed-csi | The panel's disk. It can grow, not shrink. |
metrics.enabled, metrics.port | Expose Prometheus metrics | false, 9090 | Prometheus /metrics. |
networkPolicy.enabled | none | true | The network policies for the panel and Valkey. |
The defaults are the chart's own, for an install from the command line; the
wizard sets its own defaults on every key it shows. The protected settings
are controlPlane.adminPasswordBase64 and redis.external.passwordBase64,
each base64 encoded.
Install with the Azure CLI
The same install without the portal, from bash:
az provider register --namespace Microsoft.KubernetesConfiguration --wait
az extension add --name k8s-extension --upgrade
az vm image terms accept --publisher eclypses1708534446098 \
--offer eclypses_mte_relay_server --plan eclypses_mte_relay_server
ADMIN_PW=$(openssl rand -base64 24)
echo "Admin password: $ADMIN_PW" # Store it in your password manager.
az k8s-extension create --name mte-relay-mrs --extension-type eclypses.relay-server \
--cluster-name $CLUSTER --resource-group $RG --cluster-type managedClusters \
--scope namespace --target-namespace mte-relay-mrs \
--plan-name eclypses_mte_relay_server --plan-product eclypses_mte_relay_server \
--plan-publisher eclypses1708534446098 --release-train stable \
--config controlPlane.initialConfig.upstream=https://api.example.com \
controlPlane.initialConfig.corsOrigins=https://app.example.com \
service.exposure=public redis.mode=inCluster \
relay.autoscaling.enabled=true relay.autoscaling.minReplicas=2 relay.autoscaling.maxReplicas=6 \
--config-protected controlPlane.adminPasswordBase64=$(printf '%s' "$ADMIN_PW" | base64 -w0)
az vm image terms acceptaccepts the plan's terms once per subscription; the portal does it for you.- Without
controlPlane.initialConfig.upstreamthe relay pods wait for a configuration that never comes, and Azure fails the install when its wait runs out. - Protected settings travel base64 encoded so any character in a password
survives, and a value that is not base64 fails the install. On macOS, use
base64without-w0.
Without the Marketplace
With an Eclypses license instead of the Marketplace plan, run the
on premise image on AKS with the Kubernetes
walkthrough there. On AKS, keep securityContext.fsGroup: 65532 on the
control plane, or a root owned Azure Disk blocks it from writing its data
directory, and use the service.beta.kubernetes.io/azure-load-balancer-internal
annotation for a private load balancer.
What the install runs
kubectl -n mte-relay-mrs get deploy,svc,hpa,pdb,pvc,secret,networkpolicy
| Object | Name | Purpose |
|---|---|---|
| Deployment | mte-relay | The relay pods. |
| Deployment | mte-relay-control-plane | One control plane pod. |
| Deployment and Service | mte-relay-redis | One Valkey pod and its port, with Valkey in the cluster. |
| Service | mte-relay | The relay's load balancer, or a ClusterIP on port 8080 with Cluster only. |
| Service | mte-relay-control-plane | The panel on port 8081. |
| HorizontalPodAutoscaler | mte-relay | With autoscaling on. |
| PodDisruptionBudget | mte-relay | When the minimum is two or more relay pods: keeps all but one serving while a node drains. |
| PersistentVolumeClaim | mte-relay-control-plane-data | The panel's Azure Disk. |
| Secret | mte-relay-control-plane | The admin password and the token relay pods use to reach the panel. |
| Secret | mte-relay-redis | The Redis URL the relay pods use, and the Valkey or cache password. |
| Secret | mte-relay-metrics | The bearer token for /metrics, with metrics on. |
| NetworkPolicy | mte-relay-control-plane, mte-relay-redis | Admit only relay pods to Valkey and to the panel. With the internal load balancer or metrics on, ports 8081 and 9090 also admit the cluster's network. |
Every pod runs as a non root user with a read only root filesystem, under the
restricted Pod Security Standard, and only on Linux amd64 nodes. Clusters
with Azure CNI powered by Cilium, as the wizard creates, or another policy
engine enforce the network policies.
Server Configuration
MTE Relay Server is configured with environment variables. On Azure, the control plane holds the configuration and every relay pod reads it from there; see the control plane.
- DOMAIN_MAP: routing format, host matching, and examples.
- Environment variables: every setting and its default.
- Limits and scaling: the body cap, rate limits, and what two or more relay pods require.
- CORS and WAF: what a firewall in front of the relay must allow.
Testing and Health Checks
GET /api/mte-echoandHEAD /api/mte-echoon port 8080 answer for anyHost, outside every rate limit, with{"message":"true","timestamp":"2026-01-04T10:30:45.123456789Z"};?msg=testmakes"message":"test", always a JSON string. Any other method gets405withAllow: GET, HEAD.GET /healthzon port 9091 answers200{"status":"ok","phase":"<phase>"}for as long as the process runs.GET /readyzon port 9091 answers200{"status":"ready","phase":"ready"}while the relay takes traffic, or503{"status":"not_ready","phase":"<phase>","reason":"<code>"}otherwise, withreasononly when there is one. Both takeGETandHEAD. The extension points the pods' readiness and liveness probes at them.- Health check path for scanners and load balancers:
HEALTH_CHECK_PATH, off by default, mounts a health answer on port 8080 at a path you pick, for example/health.GETandHEADthere answer200{"status":"ready"}while the relay serves and503{"status":"not_ready"}once shutdown begins, for anyHostand outside every rate limit. Set it under Configuration for a security scanner that can only reach the relay port. See Health checks and scanners. GET /api/mte-relay?frameVersions=2returns a client id and the discovery document for anyHostwhile the*route from the install exists, and for each host under Proxies after that.
Every probe, echo, and health check path answer carries
Content-Type: application/json,
Cache-Control: no-store, no-transform, private, and
X-Content-Type-Options: nosniff; the echo adds X-MTE-Relay-Request-Id.
Monitoring
- Enable Container insights to collect pod logs and node and pod metrics:
az aks enable-addons -a monitoring -g $RG -n $CLUSTER. - MTE Relay v5 writes structured JSON logs. The dashboards and alert rules under Monitoring and Integrations apply once your log collector ships them.
- With Expose Prometheus metrics on, scrape port 9090 on each relay pod
and the panel with the bearer token from
kubectl -n mte-relay-mrs get secret mte-relay-metrics -o jsonpath='{.data.METRICS_TOKEN}' | base64 -d. Port 9090 is not on the Services, so scrape the pods.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| The Cluster tab shows "The value must not be empty" under Kubernetes version, and Review + create fails validation. | The version list starts with nothing selected. Pick the first version in the list. |
| The Cluster tab says the resource group has no AKS cluster. | Basics names a different resource group. Choose the one the cluster lives in, or choose Yes to create a cluster. |
| The deployment fails with a Marketplace purchase or payment instrument error. | The subscription cannot buy Marketplace software, or the plan's terms are not accepted. Use a pay as you go or Enterprise Agreement subscription, and accept the terms in the portal or with az vm image terms accept. |
The extension shows Failed with "Timed out waiting for the resource to come to a ready/completed state". | Relay pods never turned ready. Run kubectl -n mte-relay-mrs get pods and read the log of any pod that is not 1/1. An install from the command line without controlPlane.initialConfig.upstream always ends this way. |
Relay pods stay 0/1, and /readyz reports waiting_for_configuration. | The control plane holds no configuration. Open the panel and add a host under Proxies. |
Relay pods stay 0/1 with waiting_for_control_plane. | The control plane pod is not running. Check kubectl -n mte-relay-mrs get pods and its log. |
The extension shows Failed with an error about two or more relay pods. | More than one relay pod needs shared state. Set redis.mode to inCluster or external. |
The extension shows Failed saying a setting must be base64 encoded. | A protected setting was sent as plain text. Send $(printf '%s' "$VALUE" | base64 -w0). |
az k8s-extension update stops at Do you wish to proceed? or rejects --plan-name. | Add --yes; only create takes the plan flags. |
480 no_domain_for_host. | No host in the configuration matches the Host the client sent. This happens once the * host is removed. Add the host under Proxies. |
| Browsers report a CORS error. | The app's origin is not allowed. Add it under Allowed origins for the host under Proxies. Behind the gateway, also check the firewall row below. |
Browsers report a CORS error and the gateway answers 403 on POST /api/mte-pair. | The firewall inspects request bodies. Run az network application-gateway waf-policy policy-setting update ... --request-body-check false. |
The WebApplicationFirewallPolicy stays unprogrammed with LinkedAuthorizationFailed. | The controller's identity lacks Network Contributor on the policy. Assign it as in step 7. |
Pairing answers 503 state_store_unavailable. | The relay cannot reach Redis. With Valkey, wait for its pod to restart. With Azure Managed Redis, check the private endpoint, the DNS zone link, and the access key. |
Relay pods log NOAUTH or WRONGPASS. | The access key is wrong, or access key authentication is off on the cache. Update redis.external.passwordBase64. |
Relay pods log CROSSSLOT or MOVED. | The cache uses the OSS clustering policy. Create it with EnterpriseCluster or NoCluster. |
The load balancer's address stays <pending>. | The DNS label is taken in the region, or a static IP name is wrong. Check kubectl -n mte-relay-mrs describe svc mte-relay. |
Set LOG_LEVEL to debug under Configuration for more detail, and see
Errors and recovery.
Security
Container Hardening
- Minimal base image. The runtime image uses Google's
Distroless
cc-debian13base, with no shell, package manager, or general purpose OS utilities. - Multi stage build. Build tooling and source never ship in the final image; only the compiled binary is copied forward.
- Unprivileged runtime. Every pod runs as a non root user, with a read only root filesystem, no added capabilities, and no service account token.
- Small listening surface. The image declares ports
8080,9091, and9090. The probe listener on 9091 serves the kubelet, and the Prometheus listener opens only with metrics on.
Secrets and network
- The admin password, the relay pods' token, and the Redis password live in Kubernetes Secrets in the namespace. Restrict who can read Secrets there.
- The panel holds the client secrets and, with Redis, the Redis URL. Keep port 8081 private, and see Security on the control plane page.
Vulnerability Scanning
Microsoft scans every Marketplace version before it publishes it and blocks images with findings of CVSS 7 or higher. Scan reports for the current version are available from Eclypses Support on request (see Support).
You can also generate your own scan evidence with Microsoft Defender for Containers on the cluster.
Costs
| Azure Service | Purpose |
|---|---|
| Azure Marketplace | The plan's hourly price for every running relay pod and the control plane pod |
| Azure Kubernetes Service | The cluster, its Standard tier, and the node virtual machines |
| Azure Managed Disks | The control plane's settings |
| Azure Load Balancer, or Application Gateway for Containers | Traffic to the relay pods, with TLS on the gateway |
| Azure Web Application Firewall | Request filtering on the gateway |
| Azure Managed Redis | Shared pair state for two or more relay pods |
| Azure Monitor | Logging and monitoring |
Maintenance
Release Cadence
Updated versions are published on an as needed basis, when new features, dependency updates, or fixes warrant a release. Security patches are issued out of band as needed and are not tied to a fixed calendar cadence. With automatic upgrades on, Azure installs each new 5.x version; the current release is 5.0.5.
Patch SLA
Eclypses' commitment for security patches:
- Critical vulnerabilities are addressed in a patched image within 30 days of confirmed disclosure.
- Lower severity issues are addressed in a subsequent release.
Fault Recovery
Relay pods that fail their liveness probe are restarted, and pods on a failed
node are rescheduled. With Redis, clients keep their pairs across relay pod
restarts. Without it, or when Valkey restarts, clients pair again: the browser
engine does this when a request gets 475 invalid_client_id, but that request
still fails with an MteRelayError marked repaired, and your code decides
whether to send it again.
Service Limits
- AKS: AKS Service Quotas
- Azure Monitor: Limits
- Azure Load Balancer: Limits
Secret Rotation
Rotate each host's client_id_secret under Proxies on a schedule that
fits your security policy. After a change, clients authenticate and pair
again.
Support
MTE Relay Server is a commercially supported product from Eclypses. Support and security patch SLAs (including the 30 day critical patch commitment described under Maintenance) are provided to licensed and subscribed customers. For escalations, scan reports, or hardening documentation, contact Eclypses Support:
- Email: customer_support@eclypses.com
- Hours: Monday to Friday, 8:00 AM to 5:00 PM MST (excluding holidays)