DOMAIN_MAP
DOMAIN_MAP is a JSON object. Each key is a request authority, the Host a
client sends. Each value tells the relay where to send matching requests and
how to treat them. It is the only routing setting in v5; the v4 single upstream
variables (UPSTREAM, CLIENT_ID_SECRET, CORS_ORIGINS, and the rest) are
gone.
{
"api.example.com": {
"upstream": "http://api-service:8080",
"client_id_secret": "REPLACE_WITH_A_LONG_RANDOM_SECRET",
"cors_origins": ["https://app.example.com"],
"pass_through_routes": ["/health"]
}
}
Set it as an environment variable, or manage it from the
control plane, where the Proxies
page edits one host at a time. DOMAIN_MAP is a live setting: a control plane
change reaches every worker within one poll interval without a restart.
Keys
A key is one of:
| Key form | Example |
|---|---|
| Hostname | api.example.com |
| Hostname with an explicit port | localhost:3000 |
| Bracketed IPv6 literal, with or without a port | [::1], [::1]:3000 |
| Subdomain wildcard | *.example.com |
| Global wildcard | * |
Keys are case insensitive: API.example.com and api.example.com are the
same key. A map that holds two keys that differ only by letter case is
refused, because the relay could not tell which entry to use.
A key fails validation, and the relay refuses to start, when:
- a wildcard carries a port (
*.example.com:3000), or has more than one*, or has a*anywhere but the start; - a port is outside 1 to 65535 or has leading zeros;
- an IPv6 literal is not bracketed, or carries a zone id;
- it contains
/, whitespace, control characters, or invisible characters; - the hostname is longer than 253 characters;
- another key differs from it only by letter case.
SocketX reads the same key grammar and the same member names for the members
both products have (upstream, client_id_secret, cors_origins, and
preserve_host), and matches hosts with the same rules below, so a key
selects the same requests on either product.
Host matching
For each request the relay parses the Host header into a hostname and an
optional explicit port, then tries these in order and uses the first match:
- The exact host and port, only when the request carried a port.
localhost:3000matches the keylocalhost:3000. - The exact hostname.
localhost:3000andlocalhostboth match the keylocalhost. - Subdomain wildcards, most specific first.
a.b.example.comtries*.b.example.combefore*.example.com, so overlapping wildcards always resolve the same way. - The global wildcard
*.
A few consequences worth knowing:
- No match is answered
480 no_domain_for_host, and the message names theHostthe relay saw. - A
Hostthat does not parse, such as an unbracketed IPv6 address or a bad port, is answered490 invalid_request. It is never routed through*. - A request with no
Hostheader can only match*. - A port is never inferred, so
example.comdoes not match the keyexample.com:443. - Keys and the request
Hostcompare case insensitively, soAPI.Example.commatches the keyapi.example.com. Write keys in lowercase, the form clients normally send.
Auth, pairing, keepalive, CORS preflights, pass through routes, and proxying all resolve a request against the same map snapshot, so one request can never match one entry while pairing and another while forwarding.
Fields
| Field | Type | Default | Description |
|---|---|---|---|
upstream | string | required | Absolute http:// or https:// URL with a host, checked at startup so a bad value stops the relay instead of failing every request. The request path and query are appended to it, so a base path in the URL is kept as a prefix. On an outbound domain it is the base URL of the next relay. |
client_id_secret | string | generated | HMAC key that signs the client tokens for this domain. When it is missing, each relay generates a random secret at startup and logs a warning that names the hosts, which means clients pair again after every restart and replicas cannot share clients. Set an explicit value, the same one on every replica, on anything but a single local process. |
outbound | bool | false | Makes this an outbound domain: plaintext requests are framed and sent to the next relay named by upstream. |
personalization_string | string | none | Outbound domains only. A value the relay appends to the personalization strings of the pairs it creates toward the next relay. Outer whitespace is trimmed. |
allow_plain_upstream | bool | false | Outbound domains only. Accepts an http:// upstream. Without it, an outbound domain with an http:// upstream fails validation, because that hop carries the client token. |
pass_through_routes | string[] | none | Paths proxied as plain HTTP with no MTE: an exact path such as /health, or a prefix ending in * such as /public/*. Regular expressions are not supported. The relay's own /api/mte-relay, /api/mte-pair, /api/mte-keepalive, and /api/mte-echo endpoints are always served by the relay. With HEALTH_CHECK_PATH set, GET and HEAD at that path get the health answer before pass through routes as well, and other methods still reach the origin, so pick a path the origin does not use. |
headers | object | none | Headers set on every upstream request, replacing a forwarded header of the same name. Names under the relay owned X-MTE-Relay- prefix are ignored. Host and hop by hop headers are dropped; use preserve_host to send the client's host. While FORWARD_HEADERS is on, the relay's own X-Forwarded-Host and X-Forwarded-Proto replace configured values unless trust_forwarded_headers is set, and a configured X-Forwarded-For becomes the start of the chain. |
cors_origins | string[] | none | Origins allowed for CORS: exact origins, *.example.com, https://*.example.com, or *, compared case insensitively. When empty or absent the relay adds no CORS headers, and a disallowed Origin is answered without them rather than refused. See CORS and WAF. |
cors_methods | string[] | GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD | Access-Control-Allow-Methods. |
cors_allowed_headers | string[] | Content-Type, Authorization, X-MTE-Relay-Route | Access-Control-Allow-Headers. A configured list replaces the default. |
cors_exposed_headers | string[] | Content-Type | Access-Control-Expose-Headers. A configured list replaces the default, and the relay always appends its own response headers. |
cors_allow_credentials | bool | true | Sends Access-Control-Allow-Credentials: true. |
cors_max_age_seconds | int | 0 | Sends Access-Control-Max-Age when above 0. |
cors_preflight_passthrough | bool | false | Stops the relay answering OPTIONS preflights with 204 itself. A preflight on a pass through route then goes to the upstream, and on an outbound domain to the next relay. A preflight to the encrypted route or an /api/mte-* endpoint then fails with 404 or 405, so leave it off on domains that serve browser SDKs. |
preserve_host | bool | false | Sends the client's host in the upstream Host header instead of the upstream's own authority. The dial address and TLS name still come from upstream. Not allowed together with outbound. |
trust_forwarded_headers | bool | false | Keeps an incoming X-Forwarded-Host and X-Forwarded-Proto instead of replacing them with what this relay observed. Enable it only on a domain fed by another relay. |
forward_browser_cookies | bool | true | Copies the browser's Cookie header upstream when the frame carries none, and moves upstream Set-Cookie headers onto the relay's real HTTP response, where a browser can store them. Applies to encrypted inbound requests. |
forward_plain_headers | string[] | none | Plain headers from the client to relay hop that the relay copies upstream, matched by name without regard to case, with * wildcards such as x-trace-*. ["*"] forwards every plain header except a fixed exclude list. A header inside the encrypted frame wins, except traceparent: the relay always copies a plain hop traceparent upstream, whatever this list says. Applies to encrypted inbound requests. |
request_seconds | int | REQUEST_SECONDS | Request silence deadline for this domain. 0 uses the global value. |
response_seconds | int | RESPONSE_SECONDS | Whole response bound for this domain's encrypted requests. Pass through routes and outbound domains ignore it. 0 uses the global value. |
idle_seconds | int | IDLE_TIMEOUT_SECONDS | Response silence deadline for this domain's encrypted requests. On pass through routes and outbound domains it only limits each write to the client. 0 uses the global value. |
ping_seconds | int | PING_INTERVAL_SECONDS | PING cadence advertised for this domain. 0 uses the global value. |
response_coalesce_ms | int | RESPONSE_COALESCE_MS | Response coalescing window for this domain, from 0 to 1000. Unlike the timers, 0 turns coalescing off for the domain; leave the field out to use the global value. |
Discovery advertises the per domain values a client needs: pass through routes, the plain header list, the cookie setting, and the per domain timers. See Discovery.
Headers the plain hop never forwards
No forward_plain_headers entry, including *, can forward these: Host,
Content-Type, Content-Length and every other Content-* header,
Accept-Encoding, Cookie (governed by forward_browser_cookies), Expect,
any X-MTE-Relay-* header, and hop by hop headers.
Forwarding headers
The relay builds a new request for the upstream, so it stamps the standard
forwarding headers unless FORWARD_HEADERS is false:
| Header | Value |
|---|---|
X-Forwarded-Host | The host the client requested, port included. It replaces any value the client sent, in the clear or inside the frame, unless the domain sets trust_forwarded_headers. |
X-Forwarded-Proto | A value from a trusted relay, then a load balancer's X-Forwarded-Proto, then https or http from the relay's own connection. |
X-Forwarded-For | The connection's peer address, appended to any existing chain. TRUSTED_PROXIES does not change it. |
Examples
A single API behind MTE Relay Server:
{
"api.example.com": {
"upstream": "http://api-service:8080",
"client_id_secret": "REPLACE_WITH_A_LONG_RANDOM_SECRET",
"cors_origins": ["https://app.example.com"],
"pass_through_routes": ["/health"]
}
}
Several services plus a catch all entry. Every entry here is an encrypted inbound domain; only the listed pass through routes accept plain HTTP.
{
"billing.example.com": {
"upstream": "http://billing-service:8080",
"client_id_secret": "REPLACE_WITH_SECRET_1",
"pass_through_routes": ["/ready", "/live"],
"cors_origins": ["https://app.example.com"]
},
"auth.example.com": {
"upstream": "http://auth-service:3000",
"client_id_secret": "REPLACE_WITH_SECRET_2",
"cors_origins": ["https://app.example.com"],
"cors_methods": ["GET", "POST", "OPTIONS"]
},
"*": {
"upstream": "http://default-backend:8080",
"client_id_secret": "REPLACE_WITH_SECRET_3"
}
}
A development setup where one port gets its own upstream:
{
"localhost:3000": { "upstream": "http://host.docker.internal:3000" },
"localhost": { "upstream": "http://host.docker.internal:4000" }
}
A tenant wildcard, with browser tracing headers forwarded from the plain hop.
Because the list of allowed headers is customized, it keeps Content-Type and
X-MTE-Relay-Route:
{
"*.tenants.example.com": {
"upstream": "http://tenant-api:8080",
"client_id_secret": "REPLACE_WITH_A_LONG_RANDOM_SECRET",
"cors_origins": ["https://*.tenants.example.com"],
"forward_plain_headers": ["traceparent", "tracestate"],
"cors_allowed_headers": ["Content-Type", "X-MTE-Relay-Route", "traceparent", "tracestate"]
}
}
To pass DOMAIN_MAP on one line, for example to docker run -e, keep the JSON
in single quotes:
export DOMAIN_MAP='{"api.example.com":{"upstream":"http://api-service:8080","client_id_secret":"REPLACE_WITH_A_LONG_RANDOM_SECRET"}}'
Outbound domains
An outbound domain turns the relay into the client side of a relay to relay
chain, which is how MTE API Relay protects server to server traffic. Your
service sends ordinary HTTP to the outbound relay. The relay frames each
request, sends it to the next relay named in upstream, and returns the
decoded response as ordinary HTTP with the origin's status.
- The request's
Hostselects the outbound entry, so key the entry on the name your service uses to reach the outbound relay. - The path and query pass through unchanged. There are no special request
headers; v4's
x-mte-upstreamandx-mte-outbound-tokenheaders and theoutbound_tokenfield are gone. - The relay buffers the request body before framing it, under
MAX_REQUEST_BODY_MIB. - Caller headers under the relay owned
X-MTE-Relay-prefix are dropped, the domain'sheadersare applied, and the forwarding headers are stamped. - The first request pairs with the next relay. When the next relay rejects a request with a pair, token, or state store error before its origin ran it, the relay retries on a fresh pair, up to four times. Other errors from the next relay are not retried.
- Errors on the plaintext side are JSON bodies with
code,reason,message, andcause, plus theX-MTE-Relay-Errorheader. A failure at the next relay is502, andcausenames the next relay's code, reason, and hop. A host that matches no key, or does not parse, is answered before the relay knows the domain is outbound, so that480or490arrives as a binary ERROR frame; read theX-MTE-Relay-Errorheader. See Errors and recovery. - An outbound client idle for longer than
SESSION_TIMEOUT_MINUTESis closed. A changedupstreamorpersonalization_stringrebuilds it. - The next relay must also run the MTE API Relay image, because the MAR MTE library build pairs only with another MAR.
A two relay chain looks like this. Relay A runs next to your service:
{
"relay-a": {
"upstream": "https://relay-b.partner.example.com",
"outbound": true
}
}
Your service then calls http://relay-a:8080/orders in place of the partner
API. Relay B runs in front of the API:
{
"relay-b.partner.example.com": {
"upstream": "http://partner-api:8080",
"client_id_secret": "REPLACE_WITH_A_LONG_RANDOM_SECRET",
"trust_forwarded_headers": true
}
}
Relay A stamps its observed host into the encrypted frame, and
trust_forwarded_headers on relay B keeps it, so the API sees the host your
service asked for. preserve_host is refused on an outbound domain; set it on
relay B's inbound domain when the API needs that host in its Host header.
Validation
A map that breaks any of these rules is refused:
- every key follows the key rules, and no two keys differ only by letter case;
- every entry is an object with a non empty
upstream; upstreamis an absolutehttp://orhttps://URL with a host;outboundandpreserve_hostare not both set;- an outbound domain with an
http://upstream setsallow_plain_upstream; request_seconds,response_seconds,idle_seconds, andping_secondsare not negative;response_coalesce_msis from 0 to 1000;forward_plain_headersentries are non empty HTTP header names with optional*wildcards, and*on its own is not combined with other entries.
A standalone relay also refuses an empty map. The control plane adds warnings
that never block a save: a key it does not recognize (usually a misspelling,
which workers ignore), a host without client_id_secret, and an upstream with
a base path.
A relay that had to generate a client_id_secret logs one warning at startup
that names every such host: Generated a client_id_secret for DOMAIN_MAP entries api.example.com: it does not survive a restart and is not shared across instances, so set client_id_secret explicitly for anything but a single local process. SocketX logs the same line.