Skip to main content
Version: 5.x

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.

Example MTE API Relay diagram

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.

info

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 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, 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, and https://dummyjson.com for one step, two public test APIs, 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-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.

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

note

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.

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. That worker is the inbound relay. The outbound relay does not talk to the control plane, so the panel never lists it.

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, inbound. 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. 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's DOMAIN_MAP, and the outbound relay calls the inbound relay as inbound, 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 its upstream is http://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 with 502 and cause 480. 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 inbound service.
    • Client secret is generated for you. Leave it.

    Setup wizard, Hosts step, with inbound 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 inbound, the Compose service name, in the Service name field and the commands fill in.

Fleet page with the inbound worker in sync

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:

  1. Choose Proxies in the sidebar, then the inbound row.
  2. Under Destination, replace https://jsonplaceholder.typicode.com with https://dummyjson.com.
  3. 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:

# 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 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.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 the inbound relay: a load balancer, a reverse proxy, or an ingress. Have it pass the original Host header through, and raise its idle timeout above IDLE_TIMEOUT_SECONDS (90 by default) for routes that stream. The outbound relay then points its upstream at the terminator's https:// address and needs no allow_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 of inbound.
  • 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 inbound 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 while you set up: under Configuration for the inbound relay, where it applies live, and as LOG_LEVEL: debug in the outbound relay's environment. Set both back to info after.

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:

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-api-relay.yaml. Change the two values in the Secret; everything else works as written.

mte-api-relay.yaml
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 its DOMAIN_MAP and 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-alpine Deployment with a Service in this namespace, set REDIS_URL under Configuration to its address, for example redis://redis:6379, restart the inbound relays, then kubectl -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 a LoadBalancer Service with TLS in front, and an outbound relay on the calling side points its upstream at that https:// 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.

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-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
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.
  • The inbound relay logs 480 no_domain_for_host, and the caller of the outbound relay gets 502 with cause 480: the hostname the inbound relay was reached by and the hosts under Proxies differ. Compare oc get route mte-api-relay-inbound -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 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.

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 env entry, 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_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 how the walkthrough above runs the inbound relay.
  • Standalone: set DOMAIN_MAP, which maps each request Host to 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 upstream must be https://, unless the entry also sets allow_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 original Host header through.
  • trust_forwarded_headers on the inbound entry keeps the host the outbound relay observed in X-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_host is refused on an outbound entry. Set it on the inbound entry when the API needs the original host in its Host header.

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:

SettingWhy
COMPANY_NAME, LICENSE_KEYThe defaults are a development license. Set your production license.
client_id_secret on the inbound entriesWithout 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 relayNeeded when two or more inbound replicas serve the same outbound relays.
CLIENT_MAX_PAIRS on the outbound relayThe 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 relayAt 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_MIB0, 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-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.
  • 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​

SymptomCause 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_failedExpected 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 inbound.
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 outbound relay exits at startup, and its log says is outbound to a plain http:// relay; set 'allow_plain_upstream' to accept the plain hopIts 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 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 127.0.0.1:8080 failed: port is already allocated, or the same for 8081, 9091 or 9092Another 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.
480 no_domain_for_hostThe 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 causeThe inbound relay or the API failed. cause holds the inbound relay's code and reason.
502 upstream_connect_failed without a causeThe 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_capacityThe 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 mismatchOne 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 credentialsThe 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 saveIt 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 requestThe Host header does not parse, for example an unbracketed IPv6 address.
The outbound relay keeps pairing againAn 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 issuesMake sure REDIS_URL is reachable from every inbound relay.
Cannot reach the upstreamMake 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-debian12 base, 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 nonroot user. 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. The probe listener on 9091 opens unless PROBE_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: