MTE Relay Server On-Premise Deployment
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 (browser, iOS, Android). It cannot pair with an MTE API Relay, because the two images link different MTE library builds. v5 relays serve only v5 clients; see Upgrading from v4.
What you will run
Every deployment on this page runs two containers from the same image:
- The control plane is the admin panel, on port 8081. You sign in to it, enter your license and the hosts the relay serves, and it stores that configuration on a volume.
- The worker is the relay itself, on port 8080. It carries only the control plane's address and a shared token. It fetches its configuration from the control plane and starts serving as soon as you save it.
browser or mobile app ---> worker, port 8080 ---> your API
|
| fetches its configuration
v
you, in a browser ---> control plane, port 8081
The walkthrough below runs both on your own machine with Docker Compose, in about 20 minutes, and then shows the same two containers on Kubernetes and OpenShift. A relay can also run alone with its routing in an environment variable; that is covered at the end, under A single relay without the control plane.
Before you start
- Docker with Compose v2. Docker
Desktop on Windows or macOS includes it; on Linux, Docker Engine with the
docker composeplugin. - The AWS CLI. It signs Docker, and later Kubernetes, in to the Eclypses container registry. Each sign in lasts 12 hours.
- An AWS Access Key ID and AWS Secret Access Key from Eclypses, which grant access to that registry. If you do not have them yet, ask your Eclypses contact or customer_support@eclypses.com.
- No license key. A development license is built into the image, and the wizard lets you enter a production license later.
- Ports 8080, 8081 and 9091 free on your machine. If one is taken, change only
the left number of its
ports:line, everywhere on this page. The right number is the port inside the container and stays as it is. - An API to put behind the relay. The examples use
https://jsonplaceholder.typicode.com, a public test API, so nothing of yours is needed for the first run. - Windows: in Windows PowerShell 5.1 the word
curlruns a different command, so typecurl.exein the checks below, or use PowerShell 7.3 or later, or Git Bash. - Apple Silicon: the image is
linux/amd64only. Docker Desktop runs it under emulation. Add--platform linux/amd64to thedocker pullbelow, andplatform: linux/amd64under eachimage:line of the Compose file. Kubernetes nodes have to be amd64.
Kubernetes, OpenShift, and knowledge of either are not needed for the first run.
Get the image
Configure the AWS CLI
Create an AWS CLI profile with the credentials from Eclypses:
aws configure --profile eclypses-customer-on-prem
When prompted:
- AWS Access Key ID: the ID provided by Eclypses.
- AWS Secret Access Key: the secret key provided by Eclypses.
- Default region name:
us-east-1 - Default output format:
json
Pull the image
Sign Docker in to the Eclypses registry. The login is valid for 12 hours.
- bash
- PowerShell
aws ecr get-login-password \
--region us-east-1 \
--profile eclypses-customer-on-prem |
docker login --username AWS \
--password-stdin 321186633847.dkr.ecr.us-east-1.amazonaws.com
aws ecr get-login-password `
--region us-east-1 `
--profile eclypses-customer-on-prem |
docker login --username AWS `
--password-stdin 321186633847.dkr.ecr.us-east-1.amazonaws.com
Then pull the image from that registry. It is about 45 MB:
docker pull 321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-relay-server:5.0.5
Run it with Docker Compose
1. Create the Compose file
Create a folder named mte-relay and, inside it, a file named
docker-compose.yaml with this content. Nothing in it needs to change for the
first run, apart from adding platform: linux/amd64 on Apple Silicon.
services:
control-plane:
image: 321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-relay-server:5.0.5
# The image runs as an unprivileged user and a fresh named volume is owned
# by root, so the control plane runs as root to write /data. The section
# "Before you run it on a server" shows the unprivileged alternative.
user: "0:0"
environment:
MODE: control # run the control plane, not a relay
LISTEN_PORT: "8081" # the admin panel port
CONTROL_PLANE_TOKEN: change-me-shared-token # the worker must send this same token
CONTROL_PLANE_DATA_DIR: /data # where the saved configuration lives
ADMIN_PW: change-me-admin-password # the password of the admin user
volumes:
- ./data:/data
ports:
- "127.0.0.1:8081:8081" # the admin panel, this machine only
restart: unless-stopped
relay:
image: 321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-relay-server:5.0.5
hostname: relay # the name the Fleet page shows
environment:
CONTROL_PLANE_URL: http://control-plane:8081 # where the configuration comes from
CONTROL_PLANE_TOKEN: change-me-shared-token # must match the control plane
ports:
- "8080:8080" # the relay port your clients call
- "127.0.0.1:9091:9091" # health and readiness probes, this machine only
depends_on:
- control-plane
restart: unless-stopped
volumes:
control-plane-data:
The worker has no routing of its own. It connects to the control plane with the shared token and waits for the first saved configuration.
2. Start it
From the mte-relay folder:
docker compose up -d
docker compose ps
docker compose ps lists both containers as Up:
NAME IMAGE COMMAND SERVICE STATUS PORTS
mte-relay-control-plane-1 ...mte-relay-server "/app/relay" control-plane Up 5 seconds 127.0.0.1:8081->8081/tcp
mte-relay-relay-1 ...mte-relay-server "/app/relay" relay Up 5 seconds 0.0.0.0:8080->8080/tcp, 127.0.0.1:9091->9091/tcp
The worker is up but not serving yet. docker compose logs relay ends with
these two lines, which is the expected state. configVersion 1 is the empty
starting configuration; your first save becomes v2.
relay-1 | {"level":"info","phase":"waiting_for_configuration", ... "message":"Relay lifecycle phase changed"}
relay-1 | {"level":"info","configVersion":1, ... "message":"No routes configured yet; waiting for a DOMAIN_MAP from the control plane"}
Its readiness probe says the same:
curl -i http://localhost:9091/readyz
HTTP/1.1 503 Service Unavailable
{"status":"not_ready","phase":"waiting_for_configuration","reason":"no_routes"}
Port 8080 answers nothing until the first configuration is saved. A request to
it now ends with curl: (52) Empty reply from server, because Docker accepts
the connection and the relay has not opened its port yet.
3. Sign in to the control plane
Open http://localhost:8081 in a browser. Sign in as
admin with the ADMIN_PW value from the Compose file,
change-me-admin-password.

The dashboard says the installation is not configured yet, and that one worker is connected and waiting for a routing map.

4. Run the setup wizard
Choose Start setup. The wizard has five steps, and one save at the end publishes everything together. Finish it in one sitting: the draft lives in the browser tab, and a refresh discards it.
-
Workers lists the connected worker,
relay. Choose Continue. -
License has two fields, Company name and License key. Leave both empty to keep the built in development license for now, or enter yours. Choose Continue.
-
Hosts maps the hostname clients send to the API that answers it. Enter:
- Host:
localhost, without a port. This is the hostname in the requests you are about to send,http://localhost:8080/.... The relay picks the entry whose host matches the request, so the two have to agree. Hosts you add here appear under Proxies after setup, where every later change is made. - Destination:
https://jsonplaceholder.typicode.com, or your own API's URL. An API running on this machine is reached from the container ashttp://host.docker.internal:PORT, not aslocalhost. On Linux that name needsextra_hosts: ["host.docker.internal:host-gateway"]on therelayservice. - Client secret is generated for you. Leave it.

Choose Continue.
- Host:
-
Advanced holds every other setting with its default. Nothing is needed for the first run. Choose Continue.
-
Review shows everything that will be published. Choose Save and start workers.
The page reports Configuration saved as v2 and follows the worker until it
reads 1/1 worker ready on v2. That takes a few seconds.

5. Check it
Fleet in the sidebar shows the worker in sync with config v2, and offers
the restart and scale commands for your runtime. Type relay, the Compose
service name, in the Service name field and the commands fill in.

The readiness probe is 200 now:
curl -i http://localhost:9091/readyz
HTTP/1.1 200 OK
{"status":"ready","phase":"ready"}
And the relay port answers. The auth request below is the first thing every
MTE Relay client sends. Its answer proves that this is a v5 relay, because
frameVersions lists 2, and that the localhost host is routed; an unrouted
host answers 480 no_domain_for_host instead:
curl -s "http://localhost:8080/api/mte-relay?frameVersions=2"
{"clientId":"...","relay":{"discoverySchema":1,"buildVersion":"5.0.5","frameVersions":[2], ...}}
The echo answers for any host and is a quick liveness check:
curl "http://localhost:8080/api/mte-echo?msg=hello"
{ "message": "hello", "timestamp": "2026-10-05T21:11:53.297745737Z" }
Any other path, for example curl -i http://localhost:8080/todos/1, answers
404 page not found. That is correct: the relay serves MTE Relay clients, and
a plain request is not one. The next step after this page is a client library,
which encrypts each request and pairs with the relay. Before a browser app
calls the relay, open Proxies, choose localhost, and add the app's origin
under CORS. Then follow the
JavaScript quick start.
6. Change something without a restart
The point of the control plane is that routing changes apply while the worker runs. To see it, let one path through unencrypted:
- Choose Proxies in the sidebar, then the
localhostrow. - Under Pass-through routes, type
/todos/1and press Enter. - Choose Save changes.
Within a few seconds the request that answered 404 a moment ago reaches the API:
curl -s http://localhost:8080/todos/1
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
The worker never restarted. The Fleet page reads config v3, and
curl -si http://localhost:8080/todos/1 shows X-Mte-Relay-Config-Version: 3
on the response. Routing, the log level,
body limits, forwarded headers, and the health check path apply this way.
Other settings are stored at once and take effect the next time the worker
starts; the Fleet page marks them as pending and shows the command, which for
Compose is docker compose restart relay. See
Live versus restart fields.
7. Stop and start it again
docker compose down # stop and remove the containers, keep the volume
docker compose up -d # start again
The saved configuration lives on the control-plane-data volume, so after
up -d the worker goes straight to ready without a visit to the panel. To
start over from an empty control plane, remove the volume too:
docker compose down -v
Before you run it on a server
The file above is for one machine. These are the changes for a server.
Credentials
Generate a long random token and a real admin password, and keep them out of the Compose file:
- bash
- PowerShell
# Run twice: once for the token, once for the password.
openssl rand -hex 32
# Run twice: once for the token, once for the password.
$b = New-Object byte[] 32
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b)
-join ($b | ForEach-Object {$_.ToString("x2")})
Put them in a file named .env next to docker-compose.yaml, readable only by
the operators who deploy it:
CONTROL_PLANE_TOKEN=<the first value>
ADMIN_PW=<the second value>
Compose reads .env on its own. Replace the three literal values in the
Compose file with these references, in the control-plane service:
CONTROL_PLANE_TOKEN: ${CONTROL_PLANE_TOKEN:?set it in .env}
ADMIN_PW: ${ADMIN_PW:?set it in .env}
and in the relay service:
CONTROL_PLANE_TOKEN: ${CONTROL_PLANE_TOKEN}
Enter your production license in the wizard's License step, or later under Configuration. The built in license is for development.
The configuration volume
The control plane keeps everything it stores under CONTROL_PLANE_DATA_DIR:
the configuration and its version history, service tokens, and the session
signing secret. The image runs as the unprivileged user 65532, and a fresh
named volume is owned by root, which is why the file above runs the control
plane as root. To keep it unprivileged instead, create the volume and hand it
to that user before the first docker compose up, then delete the user
line. Start from docker compose down -v, so no root owned files remain from
the walkthrough. Compose prefixes the volume name with the folder name:
docker volume create mte-relay_control-plane-data
docker run --rm -v ./data:/data busybox chown 65532:65532 /data
Without either, the control plane exits at startup with
persist admin secret: write temp file: open /data/admin_jwt_secret.tmp: permission denied.
See Backup and restore for what to
copy and how.
Networks and TLS
- Keep port 8081 and the probe port 9091 off public networks. The panel holds
the license key and client secrets, and the probes are for your orchestrator
or load balancer only. Binding both to
127.0.0.1as above, behind a reverse proxy or a VPN, is enough on a single host. - The relay serves plain HTTP on 8080. Put a TLS terminator in front of it:
a load balancer, a reverse proxy, or an ingress. Raise its idle timeout
above
IDLE_TIMEOUT_SECONDS(90 by default) for routes that stream. - The image is distroless, so a Compose
healthcheck, which runs a command inside the container, cannot work. Anything outside the container checksGET /readyzon the probe port. A load balancer that can only reach the relay port usesHEALTH_CHECK_PATH, which the panel applies live, orGET /api/mte-echo. See Testing and health checks. - Add
RELAY_RUNTIME: composeto therelayservice so the Fleet page picks the Compose commands on its own. Workers can tell Docker from Kubernetes, but not Compose from Swarm. - Set
LOG_LEVELtodebugunder Configuration while you set up, where it applies live, and set it back toinfoafter.
More than one worker
Two or more workers behind a load balancer need two things: the same client secret per host, which the wizard already generates and every worker shares, and a Redis for shared pair state. Add both services:
redis:
image: redis:7-alpine
# Pair state is short lived; nothing here needs to survive a Redis restart.
command: ["redis-server", "--save", "", "--appendonly", "no"]
restart: unless-stopped
relay-2:
image: 321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-relay-server:5.0.5
hostname: relay-2
environment:
CONTROL_PLANE_URL: http://control-plane:8081
CONTROL_PLANE_TOKEN: ${CONTROL_PLANE_TOKEN} # from .env, as above
ports:
- "8082:8080"
depends_on:
- control-plane
restart: unless-stopped
Then open Configuration, set REDIS_URL to redis://redis:6379 in the
Redis group, and save. REDIS_URL is read when a worker starts, so run
docker compose up -d to start the new services and
docker compose restart relay for the existing worker. Put your load balancer
in front of ports 8080 and 8082. On a fresh install, setting
CONTROL_PLANE_SETUP_HINTS: '{"REDIS_URL":"redis://redis:6379"}' on the
control plane prefills the address in the wizard's Advanced step instead. See
Running more than one replica.
Run it on Kubernetes
The same two containers, as two Deployments. You need kubectl and a cluster
with a default StorageClass, which the control plane's volume uses. minikube
has one; Docker Desktop's Kubernetes has to be switched on in its settings
first. The walkthrough was tested on minikube and works the same on a real
cluster. If the Compose stack from above is still running, stop it first with
docker compose down, because the port forwards below use the same ports.
1. Create the namespace and the pull secret
The image lives in a private registry, so the cluster needs credentials to pull it. Create them from an ECR token in the namespace the relay will use:
- bash
- PowerShell
kubectl create namespace mte-relay
kubectl -n mte-relay create secret docker-registry eclypses-ecr \
--docker-server=321186633847.dkr.ecr.us-east-1.amazonaws.com \
--docker-username=AWS \
--docker-password="$(aws ecr get-login-password --region us-east-1 --profile eclypses-customer-on-prem)"
kubectl create namespace mte-relay
kubectl -n mte-relay create secret docker-registry eclypses-ecr `
--docker-server=321186633847.dkr.ecr.us-east-1.amazonaws.com `
--docker-username=AWS `
--docker-password="$(aws ecr get-login-password --region us-east-1 --profile eclypses-customer-on-prem)"
ECR tokens expire after 12 hours. The secret above is enough for an
evaluation, but a pod scheduled after the token expires fails with
ErrImagePull and no basic auth credentials. For production, mirror the
image into a registry your organization operates and point the manifest at
that copy, or refresh the secret on a schedule.
2. Create the manifest
Create a file named mte-relay.yaml. Change the two values in the Secret;
everything else works as written.
apiVersion: v1
kind: Secret
metadata:
name: mte-relay-control-plane
type: Opaque
stringData:
CONTROL_PLANE_TOKEN: change-me-shared-token # the worker sends this token. Change it!
ADMIN_PW: change-me-admin-password # the admin panel password. Change it!
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: mte-relay-control-plane-data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 100Mi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mte-relay-control-plane
spec:
replicas: 1 # the stored configuration has one writer
strategy:
type: Recreate # a rolling update would briefly run two control planes on one volume
selector:
matchLabels:
app: mte-relay-control-plane
template:
metadata:
labels:
app: mte-relay-control-plane
spec:
imagePullSecrets:
- name: eclypses-ecr
# The image runs as user 65532. Without fsGroup a fresh volume is owned
# by root and the control plane cannot write to it. Remove on OpenShift.
securityContext:
fsGroup: 65532
containers:
- name: control-plane
image: 321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-relay-server:5.0.5
env:
- name: MODE
value: control
- name: LISTEN_PORT
value: "8081"
- name: CONTROL_PLANE_DATA_DIR
value: /data
envFrom:
- secretRef:
name: mte-relay-control-plane
ports:
- containerPort: 8081
volumeMounts:
- name: data
mountPath: /data
readinessProbe:
httpGet: { path: /healthz, port: 8081 }
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: 8081 }
initialDelaySeconds: 10
periodSeconds: 10
volumes:
- name: data
persistentVolumeClaim:
claimName: mte-relay-control-plane-data
---
# ClusterIP on purpose: the panel holds the license key and client secrets.
apiVersion: v1
kind: Service
metadata:
name: mte-relay-control-plane
spec:
selector:
app: mte-relay-control-plane
ports:
- port: 8081
targetPort: 8081
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mte-relay
spec:
replicas: 1
selector:
matchLabels:
app: mte-relay
template:
metadata:
labels:
app: mte-relay
spec:
imagePullSecrets:
- name: eclypses-ecr
containers:
- name: relay
image: 321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-relay-server:5.0.5
env:
- name: CONTROL_PLANE_URL
value: http://mte-relay-control-plane:8081
- name: CONTROL_PLANE_TOKEN
valueFrom:
secretKeyRef:
name: mte-relay-control-plane
key: CONTROL_PLANE_TOKEN
ports:
- containerPort: 8080
name: relay
- containerPort: 9091
name: probe
# Readiness is 503 until the first configuration is saved, so the
# pod shows 0/1 and stays out of the Service without being restarted.
# Liveness stays 200 the whole time; never point it at /readyz.
readinessProbe:
httpGet: { path: /readyz, port: probe }
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet: { path: /healthz, port: probe }
initialDelaySeconds: 5
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: mte-relay
spec:
selector:
app: mte-relay
ports:
- port: 8080
targetPort: relay
3. Apply it and sign in
kubectl -n mte-relay apply -f mte-relay.yaml
kubectl -n mte-relay get pods
The first image pull takes up to a minute; kubectl -n mte-relay get pods -w
watches. Then the control plane pod is 1/1 Running and the worker is
0/1 Running. The 0/1 is expected: the worker is alive and waiting for its
first configuration, and readiness keeps it out of the Service until then.
NAME READY STATUS RESTARTS AGE
mte-relay-84b8c747b6-tn6jh 0/1 Running 0 20s
mte-relay-control-plane-8676fc8db6-bnj9w 1/1 Running 0 20s
The panel is not exposed outside the cluster. Reach it with a port forward, which keeps running in its own terminal:
kubectl -n mte-relay port-forward svc/mte-relay-control-plane 8081:8081
Open http://localhost:8081, sign in as admin with
the ADMIN_PW value from the Secret, and run the wizard exactly as in
step 4 above: Host localhost, Destination the API.
The Workers step lists the pod by name, and the Fleet page reports its runtime
as Kubernetes. When the save completes, the worker pod turns 1/1:
kubectl -n mte-relay get pods
4. Check it
Forward the relay Service in another terminal and run the same checks as in step 5:
kubectl -n mte-relay port-forward svc/mte-relay 8080:8080
curl -s "http://localhost:8080/api/mte-relay?frameVersions=2"
curl "http://localhost:8080/api/mte-echo?msg=hello"
curl -i http://localhost:8080/todos/1 # 404 page not found, as on Compose
The localhost key works through the port forward because that is the
hostname in these requests. An application inside the cluster calls
http://mte-relay:8080, and clients outside it come through your Ingress
hostname, so add each of those as a host under Proxies with the same
Destination, or use the host clients will send from the start. Several hosts
can point at the same API.
5. Operate it
- Restart fields. Settings that need a worker restart are applied by a
rollout:
kubectl -n mte-relay rollout restart deployment/mte-relay, which the Fleet page shows too. Live settings, routing among them, need nothing. - More workers. Run a Redis the cluster can reach, a managed one or a
redis:7-alpineDeployment with a Service in this namespace, setREDIS_URLunder Configuration to its address, for exampleredis://redis:6379, restart the workers, thenkubectl -n mte-relay scale deployment/mte-relay --replicas=3. The client secret the wizard generated is already shared. See More than one worker. - Exposure. The relay Service is
ClusterIP. In production expose it with your Ingress or aLoadBalancerService with TLS in front; keep the control plane Service as it is and reach it with a port forward or an internal ingress. - Remove everything:
kubectl delete namespace mte-relay.
Source: Kubernetes Deployments
Run it on OpenShift
Red Hat OpenShift runs the Kubernetes manifest above with three changes: the
image pull secret is created with oc, the fsGroup line goes, and a Route
exposes the relay instead of a port forward.
MTE Relay images run as an unprivileged user and listen on port 8080, so they
run under the default restricted-v2 security context constraint (SCC) once
fsGroup is removed. No elevated privileges or custom SCC are required.
1. Log in and create a project
A login token is available from the web console under your user name, Copy Login Command.
oc login --token=<YOUR_TOKEN> --server=https://api.<YOUR_CLUSTER_DOMAIN>:6443
oc new-project mte-relay
2. Create the pull secret
oc create secret docker-registry eclypses-ecr --docker-server=321186633847.dkr.ecr.us-east-1.amazonaws.com --docker-username=AWS --docker-password="$(aws ecr get-login-password --region us-east-1 --profile eclypses-customer-on-prem)"
oc secrets link default eclypses-ecr --for=pull
The 12 hour token warning from the Kubernetes section applies here too.
3. Edit the manifest
In mte-relay.yaml, delete these two lines from the control plane
Deployment:
securityContext:
fsGroup: 65532
The restricted-v2 SCC assigns each pod its own user and group, and the
control plane writes its volume under them. If the lines stay, the Deployment
creates no pod at all: oc get pods is empty and oc describe rs shows the
SCC validation error.
4. Add a Route for the relay
Routes are published under the cluster's wildcard apps domain:
oc get ingresses.config/cluster -o jsonpath='{.spec.domain}'
This returns something like apps.ocp.example.com. Append this object to
mte-relay.yaml, with that domain filled in:
---
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: mte-relay
annotations:
# Raise the router timeout so long running responses are not cut off
haproxy.router.openshift.io/timeout: 1h
spec:
host: mte-relay.__YOUR_APPS_DOMAIN__ # Update this value!
to:
kind: Service
name: mte-relay
port:
targetPort: 8080
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
Do not add a Route for the control plane. Reach the panel with
oc port-forward svc/mte-relay-control-plane 8081:8081, as on Kubernetes.
5. Deploy and configure
oc apply -f mte-relay.yaml
oc get pods -w # the worker stays 0/1 until the wizard is saved
oc get route mte-relay # confirm the route is published
Run the wizard through the port forward. The Host is the Route hostname,
mte-relay.apps.ocp.example.com in the example, because that is what clients
send through the Route. Requests through the Route then look like
https://mte-relay.apps.ocp.example.com/api/mte-echo?msg=hello.
Optional, for a production relay container, add resource requests and limits:
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: "2"
memory: 1Gi
- Pods stay in
ImagePullBackOff: the ECR token has expired, or the pull secret was never linked. Recreate the secret and runoc secrets linkagain. - No control plane pod appears:
fsGroupis still in the manifest. Remove it and apply again. - Requests fail with
480 no_domain_for_host: the Route hostname and the host under Proxies differ. Compareoc get route mte-relay -o jsonpath='{.spec.host}'with the Proxies list. - A service never receives an external IP: a
LoadBalancerService stays pending on most on premise clusters. KeepClusterIPand expose it with a Route.
Source: OpenShift Routes
A single relay without the control plane
A relay can also take its routing from a DOMAIN_MAP environment variable
and run alone, with no control plane. Changes then mean editing the variable
and restarting the container. This is enough for a quick test or a fixed
single host setup. Replace the secret placeholder with any long random string,
for example from openssl rand -hex 32. The PowerShell tab needs PowerShell
7.3 or later: Windows PowerShell 5.1 strips the double quotes inside the JSON,
and the relay then exits at startup because DOMAIN_MAP is not valid JSON.
- bash
- PowerShell
docker run --rm -it \
--name mte-relay \
-p 8080:8080 \
-e DOMAIN_MAP='{ "localhost": { "upstream": "https://jsonplaceholder.typicode.com", "client_id_secret": "__A_LONG_RANDOM_SECRET__", "cors_origins": ["http://localhost:5173"] } }' \
321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-relay-server:5.0.5
docker run --rm -it `
--name mte-relay `
-p 8080:8080 `
-e DOMAIN_MAP='{ "localhost": { "upstream": "https://jsonplaceholder.typicode.com", "client_id_secret": "__A_LONG_RANDOM_SECRET__", "cors_origins": ["http://localhost:5173"] } }' `
321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-relay-server:5.0.5
- Add
-e COMPANY_NAME=...and-e LICENSE_KEY=...for your production license. - The same variable works in a Compose file, a Kubernetes
enventry, or a Secret. See Configuration reference below and DOMAIN_MAP for every field.
Configuration reference
MTE Relay Server is configured with environment variables. A relay runs in one of two ways:
- Managed: set
CONTROL_PLANE_URLandCONTROL_PLANE_TOKEN, and enter the license and hosts in the control plane admin panel. The worker waits until the first configuration is saved. This is what the walkthrough above does. - Standalone: set
DOMAIN_MAP, which maps each requestHostto an upstream. It is the only required setting; everything else has a default.
A minimal DOMAIN_MAP for one API, which is also what the wizard's Hosts step
produces:
{
"relay.example.com": {
"upstream": "http://api-service:8080",
"client_id_secret": "REPLACE_WITH_A_LONG_RANDOM_SECRET",
"cors_origins": ["https://app.example.com"],
"pass_through_routes": ["/health"]
}
}
The key is the host clients send, here relay.example.com. Requests to that
host are decrypted and forwarded to http://api-service:8080, browsers on
https://app.example.com may call it, and /health is proxied as plain HTTP.
See DOMAIN_MAP for every field, host matching,
and more examples.
Before production, review these settings:
| Setting | Why |
|---|---|
COMPANY_NAME, LICENSE_KEY | The defaults are a development license. Set your production license. |
client_id_secret in each DOMAIN_MAP entry | Without it each relay generates its own secret at startup, so clients pair again after a restart and replicas cannot share clients. The wizard generates one per host. |
REDIS_URL | Needed when two or more replicas serve the same clients, so pair state is shared. |
KYBER_STRENGTH | Keep the default 1024: the browser engine always uses 1024. |
MAX_REQUEST_BODY_MIB | 0, the default, accepts any body size. Set a cap if your API has one. |
LOG_LEVEL | info by default. debug helps while you set up. |
The environment variables page lists every setting with its default, and which ones the control plane applies live.
Testing and health checks
The relay answers four kinds of checks:
-
Echo on the relay port:
GET /api/mte-echoandHEAD /api/mte-echoanswer for anyHost, outside every rate limit, with{"message":"true","timestamp":"2026-01-04T10:30:45.123456789Z"}(UTC, RFC 3339 with nanoseconds).?msg=testmakes"message":"test". The message is always a JSON string. Any other method gets405withAllow: GET, HEAD. Echo hits are logged at debug, not info. -
Probes on the probe port, 9091 by default (
PROBE_PORT,0disables it;PROBE_ADDRpicks the interface):GET /healthzanswers200{"status":"ok","phase":"<phase>"}for as long as the process runs, and never depends on configuration or on the upstream.GET /readyzanswers200{"status":"ready","phase":"ready"}while the relay takes traffic, or503{"status":"not_ready","phase":"<phase>","reason":"<code>"}otherwise;reasonis present only when there is one. Both takeGETandHEAD; any other method gets405with anAllowheader. The relay port serves neither path. Keep the probe port off public networks: it is for the orchestrator or load balancer only. -
Health check path for scanners and load balancers:
HEALTH_CHECK_PATH, off by default, mounts a health answer on the relay port at a path you pick, for example/health.GETandHEADat that path answer200{"status":"ready"}while the relay serves and503{"status":"not_ready"}once shutdown begins, for anyHost, before Host resolution, CORS, rate limits, and the access log. Use it when a load balancer or a security scanner such as Invicti can only reach the relay port, and pick a path the origin does not use. See Health checks and scanners. -
Auth, which proves a v5 relay and a routed host:
curl -s "http://localhost:8080/api/mte-relay?frameVersions=2"The answer is JSON with a
clientIdand arelaydiscovery object. Add-H "Host: relay.example.com"to test a host other than the one in the URL. See Discovery.
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.
curl -i http://localhost:9091/readyz
curl -i http://localhost:9091/healthz
curl -i "http://localhost:8080/api/mte-echo?msg=test"
curl -I http://localhost:8080/health # with HEALTH_CHECK_PATH=/health
Troubleshooting
| Symptom | Cause and fix |
|---|---|
The worker shows waiting for configuration on the dashboard, /readyz answers 503 with "reason":"no_routes", port 8080 gives Empty reply from server | Expected before the first save. Run the setup wizard, or add a host under Proxies. |
The worker is missing from the Fleet page, /readyz answers 503 with reason control_plane_unreachable or control_plane_unauthorized | The worker cannot reach CONTROL_PLANE_URL, or its CONTROL_PLANE_TOKEN differs from the control plane's. Check both values and docker compose logs relay. |
The worker exits with CONTROL_PLANE_URL is set but CONTROL_PLANE_TOKEN is empty | Set the token on the worker. A token without a URL fails the same way. |
The relay exits with DOMAIN_MAP: is not set; a standalone relay needs a DOMAIN_MAP in its environment, or set CONTROL_PLANE_URL and CONTROL_PLANE_TOKEN | The container has neither a control plane nor a routing map. Set the two control plane variables, or DOMAIN_MAP. |
The control plane exits with persist admin secret: write temp file: open /data/admin_jwt_secret.tmp: permission denied | The volume is owned by root. Keep user: "0:0", or hand the volume to user 65532 as shown under The configuration volume. |
The control plane exits with create data dir: mkdir data: permission denied | CONTROL_PLANE_DATA_DIR is unset. Set it to /data and mount a volume there. |
docker compose up fails with Bind for 0.0.0.0:8080 failed: port is already allocated, or the same for 8081 or 9091 | Another program holds the port. Change the left number of that ports: line and use the new port in every command. |
docker login or aws ecr get-login-password fails | The access key pair or the profile name is wrong, or the keys are not active yet. Run aws configure --profile eclypses-customer-on-prem again and check the values with Eclypses. |
The control plane pod stays Pending and the PVC is Pending | The cluster has no default StorageClass. Name one in the PersistentVolumeClaim under storageClassName, or set a default. |
404 page not found from the relay port | A plain request to a path the relay does not serve. Expected; the relay serves MTE Relay clients, the echo, the auth request, and pass through routes. |
480 no_domain_for_host | No host matches the Host the client sent. The message names the host the relay saw; add it under Proxies, or change the request. |
A Kubernetes pod shows ErrImagePull or ImagePullBackOff with no basic auth credentials | The pull secret is missing, in another namespace, or its 12 hour token has expired. Create it again in the relay's namespace. |
A Kubernetes worker pod stays 0/1 Running after the save | It did not receive the configuration. Check kubectl -n mte-relay logs deployment/mte-relay for the control plane address and token. |
490 invalid_request on every request | The Host header does not parse, for example an unbracketed IPv6 address. |
Clients keep pairing again, or fail with 475 invalid_client_id | A host has no explicit client_id_secret and the relay restarted, or replicas do not share the secret or REDIS_URL. |
| Browser requests fail with CORS errors | Add the app's origin to the host under Proxies. See CORS and WAF. |
| Redis connection issues | Make sure REDIS_URL is reachable from every worker. |
Set LOG_LEVEL=debug for more detail. Errors and recovery
lists every relay error code, and the control plane page has
its own table.
Security
Container hardening
MTE Relay Server is built and hardened using these practices:
- Minimal base image. The runtime image uses Google's
Distroless
cc-debian12base. It contains the application and its runtime dependencies and no shell, package manager, or general purpose OS utilities. - Multi stage build. The application is compiled in a separate
golangbuild stage. Build tooling and source never ship in the final image; only the compiled binary is copied forward. - Unprivileged runtime. The container runs as the
nonrootuser. No root privileges are required at runtime. The control plane runs as root in the Compose example only to write a root owned volume; see The configuration volume for the unprivileged alternative. - Small listening surface. The image declares ports
8080and9091and runs a single binary entrypoint. The probe listener on 9091 opens unlessPROBE_PORT=0. - No secrets baked into the image beyond the development license. Configuration is supplied at runtime through environment variables. When you use the control plane, it stores the configuration you enter, secrets included, on its own volume.
Vulnerability scanning
Container images are scanned for known vulnerabilities with Docker Scout prior to release. Scan reports for the current image tag are available from Eclypses Support on request (see Support).
Customers can also generate their own scan evidence by pushing the image to an Amazon ECR repository in their own account and enabling Amazon ECR enhanced scanning (AWS Inspector) on it. The pulled image can equally be scanned with any container scanner of your choice, for example Trivy, Grype, or your existing CI or registry scanner.
Costs
Private infrastructure costs (VMs, storage, networking, Redis clusters) are customer managed.
Maintenance
Release cadence
Updated container images 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. Each release is versioned (see the image tag, for example
5.0.5) and distributed through Eclypses' Amazon ECR repository.
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.
To receive an updated image, pull the new tag from the Eclypses ECR repository and redeploy your containers, the control plane included.
Fault recovery
Relaunch the relay container. Without Redis, pair state lives in memory, so
clients must authenticate and pair again after a restart. 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. A worker that restarts fetches its configuration from the
control plane before it serves, and keeps serving its last configuration while
the control plane is down.
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)