MTE API Relay On-Premise Deployment
Introduction
MTE API Relay protects HTTP traffic between server applications with two relays:
- An outbound relay runs next to the calling service. The service sends it ordinary HTTP; the relay encrypts each request and sends it on.
- An inbound relay runs in front of the destination API. It decrypts each request, forwards it to the API, and encrypts the response for the trip back.

Both relays in the diagram are MTE API Relay instances. The outbound relay sits next to the calling API and the inbound relay next to the target API.
Both relays run the MTE API Relay image, because the MTE library build in that image pairs only with another MTE API Relay. An MTE API Relay cannot pair with an MTE Relay Server or with the client libraries. v5 relays pair only with v5 relays; see Upgrading from v4.
What you will run
Every deployment on this page runs three 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 inbound relay serves, and it stores that configuration on a volume.
- The inbound relay is a worker, in front of your API. 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.
- The outbound relay runs next to the calling service, on port 8080. Its one route is in its own environment, so it needs no control plane and starts serving at once.
your service ---> outbound relay, port 8080 ---> inbound relay ---> your API
|
| fetches its configuration
v
you, in a browser ---> control plane, port 8081
The walkthrough below runs all three on your own machine with Docker Compose, in about 20 minutes, and then shows the same three containers on Kubernetes and OpenShift. A relay pair can also run with its routing in environment variables and no control plane; that is covered at the end, under A relay pair 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, 9091, and 9092 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 inbound relay. The examples use
https://jsonplaceholder.typicode.com, andhttps://dummyjson.comfor one step, two public test APIs, 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-api-relay:5.0.5
Run it with Docker Compose
1. Create the Compose file
Create a folder named mte-api-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-api-relay: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 inbound relay 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
inbound:
image: 321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-api-relay:5.0.5
hostname: inbound # 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:
# No 8080 here: only the outbound relay calls it, over the Compose network.
- "127.0.0.1:9091:9091" # health and readiness probes, this machine only
depends_on:
- control-plane
restart: unless-stopped
outbound:
image: 321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-api-relay:5.0.5
hostname: outbound # the name in its logs; it is standalone, so the panel never lists it
environment:
# Its only setting: requests for localhost go, encrypted, to the inbound relay.
DOMAIN_MAP: '{ "localhost": { "upstream": "http://inbound:8080", "outbound": true, "allow_plain_upstream": true } }'
ports:
- "127.0.0.1:8080:8080" # the relay port your service calls, this machine only
- "127.0.0.1:9092:9091" # its health and readiness probes, this machine only
restart: unless-stopped
volumes:
control-plane-data:
The inbound relay has no routing of its own. It connects to the control plane with the shared token and waits for the first saved configuration. Its port 8080 is not published, because only the outbound relay calls it.
The outbound relay is the opposite. Its one route is in DOMAIN_MAP, it never
talks to the control plane, and its port 8080 is published on 127.0.0.1
only, because its caller is your own service on this machine.
The outbound relay is configured in its environment here because the hop
between the two containers is plain http://. A plain hop needs
allow_plain_upstream, and the admin panel has no switch for that flag in
this release. On a server the hop is https:// behind a TLS terminator and
needs no flag. An outbound relay can then be managed by a control plane on its
own side, with the Outbound relay switch under Proxies. An outbound
entry needs no client secret, so the save warning about a missing
client_id_secret can be ignored for it. See
Before you run it on a server.
2. Start it
From the mte-api-relay folder:
docker compose up -d
docker compose ps
docker compose ps lists all three containers as Up:
NAME IMAGE COMMAND SERVICE STATUS PORTS
mte-api-relay-control-plane-1 ...mte-api-relay "/app/relay" control-plane Up 5 seconds 127.0.0.1:8081->8081/tcp
mte-api-relay-inbound-1 ...mte-api-relay "/app/relay" inbound Up 5 seconds 127.0.0.1:9091->9091/tcp
mte-api-relay-outbound-1 ...mte-api-relay "/app/relay" outbound Up 5 seconds 127.0.0.1:8080->8080/tcp, 127.0.0.1:9092->9091/tcp
The inbound relay is up but not serving yet. docker compose logs inbound
ends with these two lines, which is the expected state. configVersion 1 is
the empty starting configuration; your first save becomes v2.
inbound-1 | {"level":"info","phase":"waiting_for_configuration", ... "message":"Relay lifecycle phase changed"}
inbound-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"}
The outbound relay is ready at once, because its route is in its environment:
curl -i http://localhost:9092/readyz answers 200. Only the inbound relay
waits. A request through the outbound relay now fails, because the inbound
relay has not opened its port yet:
curl -s http://localhost:8080/todos/1
{
"code": 502,
"reason": "upstream_connect_failed",
"message": "the next relay could not be paired with"
}
The outbound relay also logs one warning at startup,
Generated a client_id_secret for DOMAIN_MAP entries localhost. An outbound
entry does not use that secret, so the warning can be ignored.
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. That worker is the inbound relay. The outbound relay does not talk to the control plane, so the panel never lists it.

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,
inbound. 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. Here the client is the outbound relay. Two hosts are in play, one per hop: your service calls the outbound relay as
localhost, the key in the outbound relay'sDOMAIN_MAP, and the outbound relay calls the inbound relay asinbound, the host you enter here. Hosts match without the port. Enter:- Host:
inbound, without a port. This is the hostname the outbound relay sends, because itsupstreamishttp://inbound:8080, the Compose service name. The inbound relay picks the entry whose host matches the request, so the two have to agree. With any other value, every request through the outbound relay fails with502and cause480. 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 theinboundservice. - 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 inbound, the Compose
service name, in the Service name field and the commands fill in.

The inbound relay's readiness probe is 200 now:
curl -i http://localhost:9091/readyz
HTTP/1.1 200 OK
{"status":"ready","phase":"ready"}
And the chain answers. The request below goes to the outbound relay, travels
encrypted to the inbound relay, and reaches the API as /todos/1:
curl -s http://localhost:8080/todos/1
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
A request with a body takes the same path, and the API's status comes back with it:
curl -s -i -d "title=hello" http://localhost:8080/posts
HTTP/1.1 201 Created
{
"title": "hello",
"id": 101
}
The inbound relay logs every request it decrypted. docker compose logs inbound
shows a line like this one for the POST:
inbound-1 | {"level":"info","method":"POST","path":"/","statusCode":200, ... "upstreamStatus":201, ... "message":"Request completed"}
The path reads / and the status 200 because the real request and its
status travel inside the encrypted frame. upstreamStatus is the API's
answer.
The echo on the outbound relay 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:46:17.578951272Z" }
Your own service makes the same change: it calls the outbound relay in place
of the API, with the same paths and no special headers. The outbound relay's
key is the host your service uses to reach it, localhost here. A service in
another container on the same Compose network reaches it as outbound:8080,
so give the entry that key instead, or use * to accept any host. See the
Usage guide.
For more calls, the demo Postman collection sends
GET, POST, PUT, PATCH, and DELETE requests to /users. Set its DOMAIN
variable to http://localhost:8080.
6. Change something without a restart
The point of the control plane is that routing changes apply while the worker runs. To see it, point the inbound relay at a different API:
- Choose Proxies in the sidebar, then the
inboundrow. - Under Destination, replace
https://jsonplaceholder.typicode.comwithhttps://dummyjson.com. - Choose Save changes.
Within a few seconds the same request answers from the other API:
curl -s http://localhost:8080/todos/1
{
"id": 1,
"todo": "Do something nice for someone you care about",
"completed": false,
"userId": 152
}
Neither relay restarted. The Fleet page reads config v3. 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 inbound. See
Live versus restart fields.
Set the Destination back to https://jsonplaceholder.typicode.com the same
way before you go on.
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 inbound relay 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 inbound 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 outbound relay
takes its license from COMPANY_NAME and LICENSE_KEY in its own
environment.
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-api-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 ports 9091 and 9092 off public networks. The
panel holds the license key and client secrets, and the probes are for your
orchestrator or load balancer only. Binding them 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 the
inbound relay: a load balancer, a reverse proxy, or an ingress. Have it pass
the original
Hostheader through, and raise its idle timeout aboveIDLE_TIMEOUT_SECONDS(90 by default) for routes that stream. The outbound relay then points itsupstreamat the terminator'shttps://address and needs noallow_plain_upstream. That hostname is what the inbound relay now receives, so add it as a host under Proxies with the same Destination, in place ofinbound. - 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 theinboundservice so the Fleet page picks the Compose commands on its own. Workers can tell Docker from Kubernetes, but not Compose from Swarm. - Set
LOG_LEVELtodebugwhile you set up: under Configuration for the inbound relay, where it applies live, and asLOG_LEVEL: debugin the outbound relay's environment. Set both back toinfoafter.
One control plane per side
On a server the two relays usually run on different networks, often for different teams. The control plane page says to run one control plane per fleet, and the two sides of a chain are two fleets with different routing. So run one control plane per side, and never point the outbound relay at the inbound side's control plane.
To manage the outbound relay too, start it with only CONTROL_PLANE_URL and
CONTROL_PLANE_TOKEN, pointing at the control plane on its own side. In that
panel, choose Proxies, then Add proxy. Enter the host your service
calls, turn on Outbound relay, enter the inbound relay's https://
address as the Destination, and choose Create proxy. An outbound entry
needs no client secret, so the save warning about a missing
client_id_secret can be ignored for it.
Secret rotation
Rotate each inbound entry's client_id_secret on a schedule that fits your
security policy. After a change, the outbound relay's token no longer verifies,
and it authenticates and pairs again.
More than one worker
Two or more inbound relays 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
inbound-2:
image: 321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-api-relay:5.0.5
hostname: inbound-2
environment:
CONTROL_PLANE_URL: http://control-plane:8081
CONTROL_PLANE_TOKEN: ${CONTROL_PLANE_TOKEN} # from .env, as above
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 inbound for the existing worker. Put your load
balancer in front of inbound and inbound-2, point the outbound relay's
upstream at it, and add the hostname the outbound relay then sends as a host
under Proxies. 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 three containers, as three 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 relays 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-api-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 inbound relay 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-api-relay: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-api-relay-inbound
spec:
replicas: 1
selector:
matchLabels:
app: mte-api-relay-inbound
template:
metadata:
labels:
app: mte-api-relay-inbound
spec:
imagePullSecrets:
- name: eclypses-ecr
containers:
- name: relay
image: 321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-api-relay: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
---
# The outbound relay calls this Service by name, so that name is the host
# you enter in the setup wizard.
apiVersion: v1
kind: Service
metadata:
name: mte-api-relay-inbound
spec:
selector:
app: mte-api-relay-inbound
ports:
- port: 8080
targetPort: relay
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mte-api-relay-outbound
spec:
replicas: 1
selector:
matchLabels:
app: mte-api-relay-outbound
template:
metadata:
labels:
app: mte-api-relay-outbound
spec:
imagePullSecrets:
- name: eclypses-ecr
containers:
- name: relay
image: 321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-api-relay:5.0.5
env:
# Standalone: its one route lives here, not in the control plane.
# The key is this relay's Service name, the host your applications call.
- name: DOMAIN_MAP
value: '{ "mte-api-relay-outbound": { "upstream": "http://mte-api-relay-inbound:8080", "outbound": true, "allow_plain_upstream": true } }'
ports:
- containerPort: 8080
name: relay
- containerPort: 9091
name: probe
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-api-relay-outbound
spec:
selector:
app: mte-api-relay-outbound
ports:
- port: 8080
targetPort: relay
3. Apply it and sign in
kubectl -n mte-relay apply -f mte-api-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 and the outbound relay are 1/1 Running, and
the inbound relay is 0/1 Running. The 0/1 is expected:
the inbound relay is alive and waiting for its first configuration, and
readiness keeps it out of the Service until then. The outbound relay has its
route already, so only the inbound relay waits.
NAME READY STATUS RESTARTS AGE
mte-api-relay-inbound-758cbd4c96-pt7cx 0/1 Running 0 20s
mte-api-relay-outbound-78995954b7-fqvxs 1/1 Running 0 20s
mte-relay-control-plane-78bc4d59cc-vtdnj 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, with one change: the Host is
mte-api-relay-inbound, the inbound Service name, because that is the
hostname the outbound relay sends. The Destination is
https://jsonplaceholder.typicode.com, or your API. The Workers
step lists the inbound pod by name, and the Fleet page reports its runtime as
Kubernetes. When the save completes, the inbound pod turns 1/1:
kubectl -n mte-relay get pods
4. Check it
Forward the outbound relay's Service in another terminal:
kubectl -n mte-relay port-forward svc/mte-api-relay-outbound 8080:8080
Then run these two checks. The first is the chain request from step 5 with one header added:
curl -s -H "Host: mte-api-relay-outbound" http://localhost:8080/todos/1
curl "http://localhost:8080/api/mte-echo?msg=hello"
The first request answers the API's JSON, as on Compose. It needs the Host
header because the outbound relay's key is its Service name,
mte-api-relay-outbound, the host applications inside the cluster call.
Without the header, curl sends Host: localhost:8080, which matches no key,
and the answer is 480 no_domain_for_host. That answer is a binary error
frame, so read it with curl -i in the X-Mte-Relay-Error header. The echo
answers for any host and needs no header.
To test from inside the cluster instead, where no header is needed:
kubectl -n mte-relay run curl --rm -i --restart=Never --image=curlimages/curl:8.10.1 -- curl -s http://mte-api-relay-outbound:8080/todos/1
An application inside the cluster calls http://mte-api-relay-outbound:8080
the same way. When an outbound relay outside the cluster reaches the inbound
relay through your Ingress, add the Ingress hostname as a host under
Proxies with the same Destination. 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-api-relay-inbound, which the Fleet page shows too. Live settings, routing among them, need nothing. The outbound relay's route lives in the manifest: edit itsDOMAIN_MAPand apply the file again, which rolls the outbound pod. - 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 inbound relays, thenkubectl -n mte-relay scale deployment/mte-api-relay-inbound --replicas=3. The client secret the wizard generated is already shared. See More than one worker. - Exposure. Both relay Services are
ClusterIP. In production the inbound relay sits behind your Ingress or aLoadBalancerService with TLS in front, and an outbound relay on the calling side points itsupstreamat thathttps://address. 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 a few changes: the
image pull secret is created with oc, the fsGroup line goes, and a Route
exposes the inbound relay outside the cluster.
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-api-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 inbound 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-api-relay.yaml, with that domain filled in:
---
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: mte-api-relay-inbound
annotations:
# Raise the router timeout so long running responses are not cut off
haproxy.router.openshift.io/timeout: 1h
spec:
host: mte-api-relay-inbound.__YOUR_APPS_DOMAIN__ # Update this value!
to:
kind: Service
name: mte-api-relay-inbound
port:
targetPort: 8080
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
The outbound relay needs no Route, because your services call it from inside
the cluster. Do not add a Route for the control plane either. 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-api-relay.yaml
oc get pods -w # the inbound relay stays 0/1 until the wizard is saved
oc get route mte-api-relay-inbound # confirm the route is published
Run the wizard through the port forward. The Host is the Route hostname,
mte-api-relay-inbound.apps.ocp.example.com in the example, because that is
what an outbound relay outside the cluster sends through the Route. That
outbound relay points its upstream at
https://mte-api-relay-inbound.apps.ocp.example.com and needs no
allow_plain_upstream. The outbound relay in this project calls the Service
directly instead, so in the same step choose Add another host and enter
mte-api-relay-inbound with the same Destination. Requests through the Route
then look like
https://mte-api-relay-inbound.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. - The inbound relay logs
480 no_domain_for_host, and the caller of the outbound relay gets502with cause480: the hostname the inbound relay was reached by and the hosts under Proxies differ. Compareoc get route mte-api-relay-inbound -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 relay pair without the control plane
Stop the Compose stack first with docker compose down, because the pair
below uses port 8080 too.
Each 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
setup. The two relays below share a Docker network, so the outbound relay
reaches the inbound relay by its container name, inbound, which is also the
inbound relay's key. 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 network create mte-relay
docker run -d --rm \
--name inbound \
--network mte-relay \
-e DOMAIN_MAP='{ "inbound": { "upstream": "https://jsonplaceholder.typicode.com", "client_id_secret": "__A_LONG_RANDOM_SECRET__" } }' \
321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-api-relay:5.0.5
docker run -d --rm \
--name outbound \
--network mte-relay \
-p 127.0.0.1:8080:8080 \
-e DOMAIN_MAP='{ "localhost": { "upstream": "http://inbound:8080", "outbound": true, "allow_plain_upstream": true } }' \
321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-api-relay:5.0.5
docker network create mte-relay
docker run -d --rm `
--name inbound `
--network mte-relay `
-e DOMAIN_MAP='{ "inbound": { "upstream": "https://jsonplaceholder.typicode.com", "client_id_secret": "__A_LONG_RANDOM_SECRET__" } }' `
321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-api-relay:5.0.5
docker run -d --rm `
--name outbound `
--network mte-relay `
-p 127.0.0.1:8080:8080 `
-e DOMAIN_MAP='{ "localhost": { "upstream": "http://inbound:8080", "outbound": true, "allow_plain_upstream": true } }' `
321186633847.dkr.ecr.us-east-1.amazonaws.com/customer/on-prem/mte-api-relay:5.0.5
curl -s http://localhost:8080/todos/1 then answers the API's JSON, as in
step 5. To remove the pair, run docker stop outbound inbound
and then docker network rm mte-relay.
- Add
-e COMPANY_NAME=...and-e LICENSE_KEY=...to both relays 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 API Relay 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 how the walkthrough above runs the inbound relay. - Standalone: set
DOMAIN_MAP, which maps each requestHostto an upstream. It is the only required setting; everything else has a default. This is how the walkthrough above runs the outbound relay.
Routing lives in DOMAIN_MAP either way. The two relays of a chain use
different kinds of entries.
Inbound relay, in front of the API. An ordinary entry keyed by the host the outbound relay uses to reach it:
{
"inbound-relay.example.com": {
"upstream": "http://api-service:8080",
"client_id_secret": "REPLACE_WITH_A_LONG_RANDOM_SECRET",
"trust_forwarded_headers": true
}
}
Outbound relay, next to the calling service. An entry with
outbound: true, keyed by the host your service uses to reach the outbound
relay, whose upstream is the inbound relay:
{
"outbound-relay": {
"upstream": "https://inbound-relay.example.com",
"outbound": true
}
}
- An outbound
upstreammust behttps://, unless the entry also setsallow_plain_upstream, because the hop carries the client token. The relay itself serves plain HTTP, so put a TLS terminator, such as a load balancer or ingress, in front of the inbound relay, and have it pass the originalHostheader through. trust_forwarded_headerson the inbound entry keeps the host the outbound relay observed inX-Forwarded-Host. Set it only on entries fed by another relay. The wizard does not set it; add it under Proxies when your API needs the host your service called.preserve_hostis refused on an outbound entry. Set it on the inbound entry when the API needs the original host in itsHostheader.
See DOMAIN_MAP for every field and the outbound domains section for how the outbound side behaves.
Before production, review these settings on both relays:
| Setting | Why |
|---|---|
COMPANY_NAME, LICENSE_KEY | The defaults are a development license. Set your production license. |
client_id_secret on the inbound entries | Without it each inbound relay generates its own secret at startup, so the outbound relay pairs again after a restart and inbound replicas cannot share it. |
REDIS_URL on the inbound relay | Needed when two or more inbound replicas serve the same outbound relays. |
CLIENT_MAX_PAIRS on the outbound relay | The outbound pair pool ceiling, 1024 by default. One pair is held per request in flight, so set it to the peak number of requests one outbound replica has in flight to the inbound relay. |
MAX_PAIRS_PER_CLIENT on the inbound relay | At least the outbound relay's CLIENT_MAX_PAIRS, 2048 by default. The outbound relay clamps its pool to this value and logs a warning when it is lower. |
MAX_REQUEST_BODY_MIB | 0, the default, accepts any body size. The outbound relay buffers each request body under this cap. |
The environment variables page lists every setting with its default, and which ones the control plane applies live.
Usage guide
Point your service at the outbound relay instead of the destination API, and keep the path and query the same. No special headers are needed; the host your service uses selects the outbound entry.
Before MTE API Relay:
curl -X GET https://api.partner.example.com/data
After, with an outbound entry keyed outbound-relay:
curl -X GET http://outbound-relay:8080/data
The outbound relay returns the destination API's status, headers, and body as
ordinary HTTP. When the chain itself fails, the answer is a JSON error with a
relay code and an X-MTE-Relay-Error header; see
Errors and recovery.
Testing and health checks
There are 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. - The chain: send a request through the outbound relay and check that it reaches the API. The inbound relay's logs show the request as completed.
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.
With the Compose file above, the inbound relay's probes are on 9091 and the outbound relay's on 9092:
curl -i http://localhost:9091/readyz # the inbound relay
curl -i http://localhost:9092/readyz # the outbound relay
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 on the outbound relay
Troubleshooting
| Symptom | Cause and fix |
|---|---|
The inbound worker shows waiting for configuration on the dashboard, /readyz on 9091 answers 503 with "reason":"no_routes", and requests through the outbound relay answer 502 upstream_connect_failed | 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 inbound. |
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 outbound relay exits at startup, and its log says is outbound to a plain http:// relay; set 'allow_plain_upstream' to accept the plain hop | Its upstream is http://. Add "allow_plain_upstream": true to the entry, or point it at the inbound relay's https:// address. |
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 127.0.0.1:8080 failed: port is already allocated, or the same for 8081, 9091 or 9092 | 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. |
480 no_domain_for_host | The outbound relay's DOMAIN_MAP has no key for the host your service used. The message names the host. When the inbound relay has no key for the host it was reached by, its log shows 480 and your service gets 502 upstream_connect_failed, or 502 with cause 480. |
502 with a cause | The inbound relay or the API failed. cause holds the inbound relay's code and reason. |
502 upstream_connect_failed without a cause | The outbound relay could not reach, authenticate with, or pair with the inbound relay. Check the outbound upstream URL, TLS in front of the inbound relay, and the inbound DOMAIN_MAP key. The outbound relay log names the reason. |
485 upstream_capacity | The outbound relay has no free pair: more requests are in flight to the inbound relay than CLIENT_MAX_PAIRS, or the pool is still growing. Raise CLIENT_MAX_PAIRS on the outbound relay and MAX_PAIRS_PER_CLIENT on the inbound relay to at least the same value; a Clamping the pool warning in the outbound log means the inbound cap is the lower of the two, and Pairing rate limited means growth is waiting on the inbound relay's pairing rate limit. CLIENT_PAIR_ACQUIRE_WAIT_MS only lengthens the wait before the error. |
502 upstream_connect_failed, and the outbound relay log shows mteProfile mismatch | One of the relays does not run the MTE API Relay image, or the two relays use different KYBER_STRENGTH values. |
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 relays' namespace. |
A Kubernetes inbound pod stays 0/1 Running after the save | It did not receive the configuration. Check kubectl -n mte-relay logs deployment/mte-api-relay-inbound 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. |
| The outbound relay keeps pairing again | An inbound host has no explicit client_id_secret and the inbound relay restarted, or inbound replicas do not share the secret or REDIS_URL. |
| Redis connection issues | Make sure REDIS_URL is reachable from every inbound relay. |
| Cannot reach the upstream | Make sure the container can resolve and connect to the target host. |
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 API Relay is built and hardened using these practices:
- Minimal base image. The runtime image uses Google's
Distroless
cc-debian12base, 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. The container runs as the
nonrootuser. 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
8080and9091. The probe listener on 9091 opens unlessPROBE_PORT=0. - Network isolation. Deploy each relay close to the service it fronts to keep unencrypted hops short.
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, or scan it with any container scanner of their choice, for example Trivy or Grype.
Costs
Private infrastructure costs (VMs, storage, networking, Redis clusters) are customer managed. No AWS charges are incurred for on premise usage.
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. The outbound relay pairs with the inbound relay again on its next request. 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 API Relay 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)