Skip to main content
Version: 5.x

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.

Example MTE Relay server diagram

info

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 compose plugin.
  • 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 curl runs a different command, so type curl.exe in the checks below, or use PowerShell 7.3 or later, or Git Bash.
  • Apple Silicon: the image is linux/amd64 only. Docker Desktop runs it under emulation. Add --platform linux/amd64 to the docker pull below, and platform: linux/amd64 under each image: 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.

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.

docker-compose.yaml
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.

Admin panel sign in screen

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

Dashboard before setup, one worker waiting, with the Start setup button

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.

  1. Workers lists the connected worker, relay. Choose Continue.

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

  3. 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 as http://host.docker.internal:PORT, not as localhost. On Linux that name needs extra_hosts: ["host.docker.internal:host-gateway"] on the relay service.
    • Client secret is generated for you. Leave it.

    Setup wizard, Hosts step, with localhost mapped to the test API

    Choose Continue.

  4. Advanced holds every other setting with its default. Nothing is needed for the first run. Choose Continue.

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

Setup wizard after the save, one worker ready on v2

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.

Fleet page with the worker in sync

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:

  1. Choose Proxies in the sidebar, then the localhost row.
  2. Under Pass-through routes, type /todos/1 and press Enter.
  3. 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:

# Run twice: once for the token, once for the password.
openssl rand -hex 32

Put them in a file named .env next to docker-compose.yaml, readable only by the operators who deploy it:

.env
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.1 as 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 checks GET /readyz on the probe port. A load balancer that can only reach the relay port uses HEALTH_CHECK_PATH, which the panel applies live, or GET /api/mte-echo. See Testing and health checks.
  • Add RELAY_RUNTIME: compose to the relay service so the Fleet page picks the Compose commands on its own. Workers can tell Docker from Kubernetes, but not Compose from Swarm.
  • Set LOG_LEVEL to debug under Configuration while you set up, where it applies live, and set it back to info after.

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:

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)"
warning

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.

mte-relay.yaml
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-alpine Deployment with a Service in this namespace, set REDIS_URL under Configuration to its address, for example redis://redis:6379, restart the workers, then kubectl -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 a LoadBalancer Service 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.

info

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
Troubleshooting
  • Pods stay in ImagePullBackOff: the ECR token has expired, or the pull secret was never linked. Recreate the secret and run oc secrets link again.
  • No control plane pod appears: fsGroup is 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. Compare oc get route mte-relay -o jsonpath='{.spec.host}' with the Proxies list.
  • A service never receives an external IP: a LoadBalancer Service stays pending on most on premise clusters. Keep ClusterIP and 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.

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 env entry, 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_URL and CONTROL_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 request Host to 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:

SettingWhy
COMPANY_NAME, LICENSE_KEYThe defaults are a development license. Set your production license.
client_id_secret in each DOMAIN_MAP entryWithout 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_URLNeeded when two or more replicas serve the same clients, so pair state is shared.
KYBER_STRENGTHKeep the default 1024: the browser engine always uses 1024.
MAX_REQUEST_BODY_MIB0, the default, accepts any body size. Set a cap if your API has one.
LOG_LEVELinfo 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-echo and HEAD /api/mte-echo answer for any Host, outside every rate limit, with {"message":"true","timestamp":"2026-01-04T10:30:45.123456789Z"} (UTC, RFC 3339 with nanoseconds). ?msg=test makes "message":"test". The message is always a JSON string. Any other method gets 405 with Allow: GET, HEAD. Echo hits are logged at debug, not info.

  • Probes on the probe port, 9091 by default (PROBE_PORT, 0 disables it; PROBE_ADDR picks the interface): GET /healthz answers 200 {"status":"ok","phase":"<phase>"} for as long as the process runs, and never depends on configuration or on the upstream. GET /readyz answers 200 {"status":"ready","phase":"ready"} while the relay takes traffic, or 503 {"status":"not_ready","phase":"<phase>","reason":"<code>"} otherwise; reason is present only when there is one. Both take GET and HEAD; any other method gets 405 with an Allow header. 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. GET and HEAD at that path answer 200 {"status":"ready"} while the relay serves and 503 {"status":"not_ready"} once shutdown begins, for any Host, 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 clientId and a relay discovery 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​

SymptomCause and fix
The worker shows waiting for configuration on the dashboard, /readyz answers 503 with "reason":"no_routes", port 8080 gives Empty reply from serverExpected 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_unauthorizedThe 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 emptySet 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_TOKENThe 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 deniedThe 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 deniedCONTROL_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 9091Another 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 failsThe 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 PendingThe cluster has no default StorageClass. Name one in the PersistentVolumeClaim under storageClassName, or set a default.
404 page not found from the relay portA 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_hostNo 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 credentialsThe 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 saveIt 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 requestThe Host header does not parse, for example an unbracketed IPv6 address.
Clients keep pairing again, or fail with 475 invalid_client_idA 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 errorsAdd the app's origin to the host under Proxies. See CORS and WAF.
Redis connection issuesMake 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-debian12 base. 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 golang build 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 nonroot user. 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 8080 and 9091 and runs a single binary entrypoint. The probe listener on 9091 opens unless PROBE_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: