Upstreams & Routing
Routing lives in its own file, upstreams.yaml, separate from config.yaml and
reloaded independently of it. The main config only points at it:
proxy:
upstream:
conf: "/etc/synapse/upstreams.yaml"
Everything on this page is the v2 schema, which is what version: 2 at the top of
the file selects. v1 files still load — see Migrating from v1.
The file is parsed on startup and on every change. The two outcomes are not the same:
- At startup, a file that fails to parse leaves the proxy with no routing table.
It logs
Failed to load upstreams configuration from: <path>and serves nothing. The file watcher never starts either, so fixing the file afterwards needs a restart. - On reload, a file that fails to parse is discarded and the previous table keeps serving. The failure is logged and counted, not applied.
So a broken edit never takes a running proxy down, but a broken file at boot leaves it with nothing to route.
config/upstreams.yaml is out of dateThe commented sample that ships beside the binary still describes older behaviour. At
0.8.8 it offers suffixed timeouts (seconds, or use suffix: 30s / 5m) and the defaults
30/120/30/60, calls sticky sessions a consistent-hash by client IP, says key: is
optional; default: <cert basename>.key, and writes a path in tls.terminate.cert.
Every one of those is wrong for the code that reads the file, and a timeout copied from it with its suffix fails the load outright. This page describes what 0.8.8 actually does; where the two disagree, the sample is the one to ignore.
The smallest file that works
Only version is required. A file containing nothing else is valid — it just routes
nothing.
Write that line in every file. A file without it is not rejected, it is read as the v1
schema, where hosts: is not a key at all — so it loads as an empty configuration and
routes nothing, saying so in an Unused upstreams config options (…) warning and in the
v1 deprecation warning.
For the same reason, a block on this page that does not start with version: 2 is a
fragment to merge into a file that does, not a file.
Terminating TLS, one backend:
version: 2
hosts:
example.com:
tls:
terminate:
cert: example.com # a certificate NAME, not a path — see below
upstream: backend:8080
TLS passthrough to a database:
version: 2
hosts:
db.example.com:
tls:
passthrough: true
upstream: 10.0.0.5:5432
Almost every block in a v2 file rejects fields it does not recognise, and the error
fails the whole load. A typo like upstreem: or a v1 key such as provider:,
internal_paths: or globals: does not get ignored — it stops the file from loading,
and the error names the unknown field.
The three that do not check are an entry inside an upstreams: list, a rewrite:
block and a redirect: block. Misspell weight: in an upstreams: entry and the file
loads with that backend quietly weighted 1.
Document structure
| Key | Type | Required | Default |
|---|---|---|---|
version | integer | yes | — (must be 2 for this schema) |
hosts | map of SNI → host | no | empty (nothing is routed) |
default_host | one host, without tls: or listeners: | no | absent — a name no host matches gets 404 |
proxy.fingerprints.forward | bool | no | true |
proxy.fingerprints.header_names | g0s | legacy | both | no | g0s |
proxy.sticky_sessions.enabled | bool | no | false |
timeouts | map | no | see Timeouts |
headers.request, headers.response | list of "Name: value" | no | empty |
transforms | map | no | empty |
methods | list of method names | no | all methods allowed |
normalize_path | bool | no | unset (the client's spelling is kept) |
max_url_length | integer (bytes) | no | unset (no limit) |
max_header_count | integer | no | unset (no limit) |
internal | list | no | empty — and never applied, see Internal paths |
methods, normalize_path, max_url_length, max_header_count, timeouts,
headers and transforms may be set at the top level, on a host, or on a route.
Hosts
A host key is the SNI name. One leading wildcard is allowed:
hosts: # key shapes only — not a whole file
example.com: # exact
"*.example.com": # any number of leading labels
A bare *, a wildcard in the middle (a.*.example.com) and an empty key are all
rejected. The error on * names the alternative: to serve every name nothing else
matches, declare a default host.
How a name finds its host
One function matches a request's name to a host key, and every per-host table — routes, header rules, transforms — is then read with the key it returns and with nothing else, so a request is one host's in all of them.
- The name itself, compared the way DNS compares names: without the root dot and
without regard to case.
Example.COM.,example.com.andexample.comare one name, and a key written in any of those spellings serves all three. - The wildcard with the longest suffix.
*.example.comservesa.example.comanda.b.example.comalike — any depth — and never the bareexample.com, which needs its own key. - The default host, when the file declares one.
A name none of the three finds is answered 404.
Any depth is the HTTP table's rule. The SNI router that serves passthrough hosts
matches a wildcard one label deep, so *.example.com passes a.example.com
through and not a.b.example.com. The difference is deliberate, and it is why
paths: is refused on a wildcard passthrough host — see
Plain HTTP on passthrough paths.
Every host picks exactly one mode
| Mode | How you select it | What it does |
|---|---|---|
| Terminate | tls.terminate with cert, with acme, or empty (terminate: {}) | Synapse ends TLS, inspects HTTP, and proxies to the backend |
| Passthrough | tls.passthrough: true, or a passthrough: block | The TLS bytes go to the backend untouched; Synapse routes on SNI alone |
Every host needs exactly one of tls.terminate, tls.passthrough: true or a
passthrough: block. A host with none of the three fails the load — tls block missing — pick one of tls.terminate or tls.passthrough: true — but the tls: block itself is
optional, which is why a passthrough: host below carries none. Setting both
passthrough: and tls.passthrough: true is an error too: they are two spellings of
one thing and the parser refuses to guess which was meant.
The mode decides which other keys are legal, and the parser enforces it rather than quietly ignoring them:
| Key | Terminate | Passthrough |
|---|---|---|
paths | yes | yes, under three conditions — below |
headers, transforms | yes | rejected |
methods, normalize_path, max_url_length, max_header_count | yes | rejected |
idle_timeout, max_connections, proxy_protocol_v2 | rejected | yes |
The default host
default_host: sits beside hosts: and serves every name no entry under hosts:
matches. Without one, such a request is answered 404.
version: 2
hosts:
example.com:
tls:
terminate:
cert: example.com
upstream: backend:8080
default_host:
paths:
/:
upstream: catch-all:8080
It is a terminating host, and it takes everything one takes: upstream:/upstreams:
or paths:, timeouts, headers, transforms, methods and the limits. Its routes are
checked against internal: for colliding paths like any other host's, and that error
names it default_host.paths."/…".
Three keys are refused rather than ignored, because they describe a name it does not have:
Written on default_host: | Outcome |
|---|---|
passthrough:, or tls.passthrough: true | Load fails — passthrough picks its backend by name before any request is read |
tls.terminate or tls.passthrough, in any form | Load fails — there is no name for a certificate to be chosen by |
listeners: | Load fails — it serves every listener a terminating host can be served on |
A client that reaches it over TLS is shown proxy.default_certificate from
config.yaml, which is what a name no host has is shown anyway.
"*" does thisA v1 host key goes into the routing table as written, and * is the key the default
host is kept under — so a v1 file gets a catch-all by naming a host "*". In a v2
file that key is refused and default_host: is the only spelling.
Terminating TLS
cert is a name, not a path
version: 2
hosts:
example.com:
tls:
terminate:
cert: example.com
upstream: backend:8080
tls.terminate.cert is matched against the logical names of the certificates
Synapse found when it scanned proxy.certificates. A logical name is the file stem
(example.com.crt → example.com), or the parent directory name when the stem is
fullchain, cert or chain (certbot's …/example.com/fullchain.pem →
example.com).
No logical name can contain a /, so a value like /etc/synapse/admin.pem can never
match. The load still succeeds — nothing checks the string at parse time — and then at
handshake Synapse logs
Certificate '/etc/synapse/admin.pem' specified in upstreams config for hostname
'example.com' not found in cert_name_map. Available certificates: [...]
and serves the default certificate instead. Clients get a certificate for the wrong name, and the only sign is that warning.
For a certificate to be available at all, the directory scan has to have found a pair:
<name>.crt beside <name>.key, or <dir>/fullchain.pem beside privkey.pem. A
.crt with no matching key is skipped.
terminate: {} is a complete TLS block. The host terminates, and names no certificate
of its own:
version: 2
hosts:
internal.example.com:
tls:
terminate: {}
upstream: backend:8080
Over TLS such a host is served with whatever the listener presents for its name — a
certificate the directory scan found for it, or the listener's default. In plain HTTP it
is served like any other host. It is the v2 spelling of a v1 host with no
certificate:, and the only way to write a host in a v2 file that is reached over
plain HTTP alone.
key: is accepted and then discardedA sibling key: under terminate parses, and is then dropped — nothing reads it, and
nothing derives a .key filename from cert. The private key actually used is always
the one found next to the certificate during the scan. Where key: still has an effect, it is a
refusal: beside acme: it is an error, and with no cert beside it, an error of its
own.
ACME
version: 2
hosts:
example.com:
tls:
terminate:
acme:
challenge_type: http-01 # http-01 | dns-01
wildcard: false # true requires challenge_type: dns-01
upstream: backend:8080
acme: {} is valid and takes every default. cert and acme are mutually exclusive —
both together is an error — and neither is valid too: that is
terminate: {}, the host with no certificate of its own.
tls-alpn-01 is accepted by the parser and is not implemented. Any value other than
dns-01 takes the HTTP-01 path, so writing it gets you http-01 without a word of
warning. Enabling ACME, its port and its storage live in proxy.acme in config.yaml;
this block only supplies the per-certificate settings.
Routes
A terminating host either names one backend for everything (upstream/upstreams), or
splits traffic by path with paths. Both together is an error, and neither is an error.
version: 2
hosts:
example.com:
tls:
terminate:
cert: example.com
paths:
/:
upstream: web:8080
/api:
upstreams:
- addr: api-1:9000
weight: 3
- addr: api-2:9000
weight: 1
timeouts:
read: 120
max_body_size: 10485760
Route paths must start with / — unless the route sets match_expr, where the key is
only a label — and matching is longest-prefix rather than exact: with / and /api
declared, /api/v2/users is served by /api and everything else by /.
Within one host, the order is: the exact path, then the
expression routes in the order of their keys, then
the longest prefix, then /. (Synapse's own internal paths are tried before all of
them, whatever the host.) A route whose backends were all unusable is skipped rather
than failing — see Backends.
| Field | Type | Default | What it does |
|---|---|---|---|
upstream | "host:port" | — | One backend. Exclusive with upstreams |
upstreams | list of "host:port" or {addr, weight} | — | Several backends; weight defaults to 1 |
methods | list | inherit | Anything else gets 405 with an Allow header — with two exemptions, below |
normalize_path | bool | inherit | Send the backend the resolved path instead of the client's spelling |
max_url_length | bytes | inherit | Over the limit gets 414 |
max_header_count | integer | inherit | Over the limit gets 431 |
max_body_size | bytes | unlimited | Over the limit gets 413 |
timeouts | map | inherit | See Timeouts |
headers, transforms | map | inherit + append | See Headers |
force_https | bool | false | Redirect plaintext to HTTPS |
ssl_enabled | bool | true for port 443 or 8443, else false | TLS to the backend — until the health probe observes otherwise |
http2_enabled | bool | false | Offer HTTP/2 to the backend. Decides anything only for a cleartext backend |
disable_access_log | bool | false | Drop this route's access-log lines |
rewrite | {match_path?, set_path} | — | Rewrite the path sent upstream; set_path required |
redirect | {status, location} | — | Answer directly; both required |
health_check | {type, path?} | probed | See Health checks |
match_expr | expression string | — | Decide the route by the request, not by the key — see below |
Four of these deserve a second look, because the name does not say which side of the proxy they act on or what else overrides them:
ssl_enabledis TLS to the backend, not from the client. Its default is inferred from the backend port: 443 and 8443 imply TLS, anything else plaintext. On a probed route it is not the last word: the probe tries HTTPS before HTTP, and once a backend has answered, what the probe saw is what Synapse speaks to it — an explicitssl_enabled: falseto a backend that answers HTTPS is overridden. Treat the key as what gets used until the first probe lands, not as a way to force plaintext. On a route that is not probed the answer differs by which kind it is: What decides TLS to the backend has the three cases.http2_enabledis settled by ALPN whenever the backend is reached over TLS, so the key only decides anything for a cleartext backend you mean to speak h2c to.- Synapse does not verify the backend's certificate when it speaks TLS upstream: neither the chain nor the hostname is checked. Upstream TLS here is encryption on the wire, not authentication of the backend.
force_httpsonly makes sense when a TLS listener exists. Configure it without one and Synapse warns once and keeps serving plaintext.
methods is not a complete gateTwo requests get past a methods: list:
HEADrides along withGET. ListGETand aHEADis served too, and theAllow:header on a 405 advertises the impliedHEADalongside it. There is no way to listGETand refuseHEAD.- An http-01 challenge is never method-gated. A request for
/.well-known/acme-challenge/<token>skips the check on whatever route covers that path, so an external ACME client (certbot, cert-manager) cannot be locked out of a renewal by a narrowmethods:list.
Everything else outside the list gets 405. Treat methods: as narrowing a route, not
as an access control.
A redirect.status outside 301, 302, 303, 307 and 308 silently becomes 302.
Routes chosen by an expression
A route with match_expr is chosen by the request rather than by its key, and its key
becomes a label — it need not start with /, and no other route's prefix matching sees
it.
version: 2
hosts:
example.com:
tls:
terminate:
cert: example.com
paths:
/:
upstream: web:8080
beta:
match_expr: 'http.request.query matches "(^|&)beta=1(&|$)"'
upstream: beta:8080
prod-writes:
match_expr: 'http.request.method eq "POST" and any(http.request.headers["x-env"][*] eq "prod")'
upstream: writes:9000
Within a host they are tried in the order of their keys, after the exact path and before the longest prefix. Sort order is the decider, so name them for the order you want.
Five things a request offers an expression:
| Field | Type | Notes |
|---|---|---|
http.request.path | string | always present |
http.request.method | string | always present |
http.request.host | string | the name the client asked for, which for a wildcard or the default host is not the host key |
http.request.query | string | the query string, without the ?; read on demand, see below |
http.request.headers | map of name → list of values | names in lower case, each with a list of values; read on demand, see below |
any, all and lower are available:
any(http.request.headers["x-canary"][*] eq "1")
any(lower(http.request.headers["x-env"][*])[*] eq "prod")
all(http.request.headers["x-env"][*] matches "^[A-Za-z]+$")
A request is only handed what the expressions of its host actually read: the query string when one asks for it, and of the headers, the ones they name — or all of them when an expression asks about headers in general.
Header names are held in lower case, as HTTP keeps them. http.request.headers["X-Env"]
would match no request at all, so the load fails instead and the error gives the
spelling to use: header names are in lower case: write "x-env" for "X-Env". A name no
header could have is refused the same way.
An expression that does not compile also fails the load — the whole file, as any other v2 error does.
The prober goes by a route's path, and an expression route has none. Two consequences:
- A
health_check:block on it is refused unless it saystype: none, rather than being accepted and ignored. - Its backends are reached by what
ssl_enabledsays, not by what a probe found — see What decides TLS to the backend. Withssl_enabledunset and backends on a mix of TLS and plaintext ports, the load fails and asks for an explicit value.
Inheritance
Global, host and route levels merge most-specific-wins — a route's max_url_length
overrides its host's, which overrides the top level's. headers and transforms are
the exception: they append rather than replace, in that order, so a route's header
rules run after its host's.
Backends
upstream takes one "host:port". A host-level upstream: given a list gets a
dedicated error pointing at upstreams:; the same mistake on a route gets serde's bare
invalid type: sequence, expected a string. upstreams takes several, each either a plain string or
{addr, weight} with a weight of 1 by default.
An empty upstreams list is an error, and so is a list where every weight is 0.
A backend address is split at the first : and the part after it parsed as a port, so
[2001:db8::1]:8080 does not parse — and the entry is skipped without any log line.
An address with no :port at all goes the same way.
What a route does when every one of its backends was skipped is worse than failing: it
counts as not declared, so the request falls through to the next shorter route —
usually / — or to a 404. One typo'd backend on /api can quietly send /api traffic
to the / backend. Name IPv6 backends by hostname instead.
Load balancing
lb: belongs on a host that names its own backends, on a route, or inside a passthrough
wire block. There is no top-level lb: — written at the top of the file it is an
unknown key and the load fails.
paths:
/api:
upstreams: [api-1:9000, api-2:9000]
lb:
method: round_robin # round_robin | random | least_request | weighted_round_robin | consistent
An unknown method is an error listing the five valid ones — in each of the three places
lb: is read. A host-level lb: on a passthrough host that declares tls:/http:
wires is not one of them: each wire reads its own, so the host-level block is dropped
without being looked at, and even method: nonsense loads there. Put it inside the wire.
lb.method only takes effect on passthrough hostsOn a terminating host or route, lb.method is validated and then dropped. Backend
selection there is always the weighted cycle, so least_request and consistent on an
HTTP route do nothing at all. Weights are honoured; the method name is not.
On a passthrough host it is honoured, and only three of the five are accepted:
round_robin, weighted_round_robin and random. least_request and consistent are
rejected outright.
Timeouts
timeouts:
connect: 5
read: 60
write: 30
idle: 60
| Timeout | Default | What it bounds |
|---|---|---|
connect | 5 | Opening the connection to the backend |
read | 60 | Reading the backend's response |
write | 30 | Writing to the backend |
idle | 60 | How long a pooled backend connection is kept for reuse |
These are integers. connect: 30s and read: 5m are type errors that fail the whole
file — including the suffixed forms some older examples show. idle is a connection
pool keepalive, not a cap on how long a connection may live.
Set them at any of the three levels; they merge most-specific-wins. For the bytes a
passthrough host passes through, only connect applies: read, write and idle are
parsed and unused there, and idle connections are bounded by its own idle_timeout
instead.
The three are not dead on such a host, though. A passthrough host with
paths: hands its merged timeouts down to those
routes, which read all four the way a terminating host's routes do — so a host-level
timeouts.read that the passed-through wire ignores still bounds its HTTP routes.
idle: 0 means the connection is not reused at allA route with timeouts.idle: 0 gets a connection of its own per request. Over HTTP/1
the request carries Connection: close upstream, so the backend closes it once the
response is read instead of it going to the pool to be closed a moment later; over
HTTP/2, and on an upgrade, the pool key alone keeps it from being reused. Use it for a
backend that must not see a second request on a connection — not as a short keepalive.
Health checks
A v2 route is health-checked by default. type: none turns probing off for one.
paths:
/api:
upstream: api:9000
health_check:
type: http # http | tcp | none
path: /healthz # http only; must start with "/"; defaults to "/"
type: tcp with a path is an error, and anything other than the three names is too —
expected tcp, http or none.
The interval and thresholds are not per route. interval,
consecutive_failures and consecutive_successes are rejected inside a terminating
route's health_check; the probe interval for terminating hosts comes from
proxy.upstream.healthcheck in config.yaml. They are accepted only under a
passthrough host's health_check:
version: 2
hosts:
db.example.com:
upstream: 10.0.0.5:5432
passthrough:
health_check:
type: tcp
interval: 10s # a suffix is REQUIRED here: ms | s | m
consecutive_failures: 3
consecutive_successes: 2
A passthrough: block with no tls:/http: wire inside it takes the host-level
upstream: and serves the TLS wire, which is why the example above needs no
tls.passthrough: true — writing both would be rejected.
interval is the one duration that needs a unitEvery other duration in this file is a bare integer of seconds. health_check.interval
is the opposite: it is a string and the unit suffix is mandatory — ms, s or m
(h is not supported). interval: 10 is an error; interval: 10s is correct. In
passthrough it is then truncated to whole seconds with a floor of one, so 1500ms
probes every second, not every two.
What decides TLS to the backend
A route that is never probed has nothing to learn the scheme from, so which key decides it depends on why the route is unprobed. Three cases, for a terminating host's routes:
| The route | Probed? | What decides TLS to the backend |
|---|---|---|
probed — no health_check: block, or type: http / type: tcp | yes | the probe — whatever the check type, it tries HTTPS before HTTP, and what answered overrides ssl_enabled. When neither answers — a backend that accepts the connection but speaks no HTTP scheme — the port decides instead, so an explicit ssl_enabled holds only until the route's first verdict |
health_check: {type: none} on a route chosen by its path | no | the backend's port: TLS on 443 and 8443, plaintext on anything else. ssl_enabled is not consulted, and a value that disagrees with the port fails the load |
chosen by match_expr | no | ssl_enabled, which is honoured as written. Left unset it is taken from the ports, and backends split across TLS and plaintext ports fail the load |
A route chosen by match_expr that also carries type: none is the last row, not the
middle one: it loads, and ssl_enabled is honoured as written. The port rule is applied to
the table of path-chosen routes, and an expression route is not in it.
The refusal in the middle row is there rather than sending TLS traffic in plaintext or
the reverse: drop type: none so the route is probed, or use a port that says what you
mean.
type: none takes no other keypath, interval, consecutive_failures and consecutive_successes are each refused
beside it, since there is no probe for them to describe. Under a passthrough host's
health_check it is accepted too, where it means the same as leaving the block out.
The port rule is not new and it is not a v2 rule. A v1 file with healthcheck: false
discards a disagreeing ssl_enabled in exactly the same way — it accepts the key and
the serving table overwrites it. The difference is only whether you are told: v1 says
nothing, v2 refuses the file.
So a v1 route that has run for years with healthcheck: false, ssl_enabled: true and
a backend on :8080 has been speaking plaintext to it, not TLS. Converting it to
health_check: {type: none} changes nothing about which scheme it speaks, and the file will not load
until the ssl_enabled goes or the port says what you mean. That is the file stopping
hiding a disagreement, not a change of behaviour to plan around.
Passthrough
The modern form names a wire explicitly. tls is routed by SNI on the TLS listener,
http by the Host header on the plaintext listener:
version: 2
hosts:
db.example.com:
passthrough:
tls:
upstreams:
- addr: 10.0.0.5:5432
weight: 1
lb:
method: round_robin # round_robin | weighted_round_robin | random
idle_timeout: 3600 # seconds; 0 = unbounded
max_connections: 500 # per host, across both wires; 0 is rejected
proxy_protocol_v2: false # prepend a PROXY v2 header to the backend
| Key | Default | Notes |
|---|---|---|
idle_timeout | 3600 | Seconds. 0 means no bound |
max_connections | no cap | Counted per host across both wires. 0 is an error |
proxy_protocol_v2 | false | Prepends a PROXY v2 header to the backend connection, ahead of any application bytes |
A passthrough host must have a backend somewhere — a wire block, or a host-level
upstream/upstreams. A wire block cannot be combined with a host-level upstream,
and protocol: (the older single-wire selector, tls or http; auto is rejected)
cannot be combined with a wire block either.
Declare passthrough.http with no passthrough.tls and Synapse warns that a TLS
connection for that name will be terminated with the default certificate rather than
passed through. If the name should never be terminated, give it a tls wire.
Plain HTTP on passthrough paths
A passthrough host can serve paths of its own in plain HTTP while TLS for the same name is still passed through untouched. The case it was built for is an ACME HTTP-01 challenge for a name whose TLS belongs to the backend.
version: 2
hosts:
db.example.com:
tls:
passthrough: true
upstream: 10.0.0.5:5432
paths:
/.well-known/acme-challenge:
upstream: 127.0.0.1:8080
Those routes are served by the same L7 pipeline that serves a terminating host's. Three conditions apply, and breaking one is refused with its reason rather than ignored:
| Condition | Why |
|---|---|
| The host key is an exact name, not a wildcard | A wildcard matches one label deep for TLS and any depth for HTTP, so the paths would be served for names this host does not pass through |
The host passes TLS only — no http: wire, no protocol: http | A plain-HTTP request for the name is copied to the backend before any path could match |
paths: has at least one entry | An empty map says nothing; list a path or drop the key |
A host-level headers: or transforms: block is still refused on a passthrough host,
paths: or no paths:. Header rules for these routes go on the route.
Listeners
version: 2
hosts:
admin.example.com:
listeners: [internal]
tls:
terminate:
cert: admin.example.com
upstream: 127.0.0.1:8080
With no listeners, a host is served on every listener. Named listeners restrict it.
A name that does not exist is dropped with a warning, and a host whose whole list is
unknown names is served nowhere — Synapse says so explicitly:
host "…" is bound to no valid listener and will not be served anywhere.
Two bindings have to admit a request, not one: the binding of the name it asked for,
and the binding of the host that will serve it. The two are not always the same host —
*.internal.example serves a.b.internal.example, a name that matches no binding of
its own.
A request a listener turns away does not simply get a 404: if the file declares a
default host, that host serves it, and only without one is the
request answered the way an unconfigured name is.
Headers and transforms
headers:
request:
- "X-Forwarded-Proto: https"
response:
- "X-Frame-Options: DENY"
Entries are "Name: value" strings. An entry with no :, an invalid header name or
value, or one naming a framing header (such as Content-Length) is skipped with a
warning rather than failing the load — Skipping unusable header entry ….
transforms is the richer form, with request_headers and response_headers rule
lists and conditional when clauses. Unlike a bad headers: entry, a transforms rule
that does not compile rejects the entire file:
Rejected upstreams configuration: …; no upstreams are served until it is fixed. The
reload loop keeps watching, so fixing the file recovers without a restart.
Fingerprint forwarding
proxy:
fingerprints:
forward: true
header_names: g0s # g0s | legacy | both
Forwarding is on by default, and the default names are the namespaced ones —
G0S-JA4, G0S-JA4H, G0S-JA4T, G0S-JA4T-Hash, G0S-JA4S, G0S-JA4TS,
G0S-JA4TS-Hash, G0S-JA4L, G0S-JA4LS, G0S-JA4X and G0S-JA4-Raw.
legacy sends the pre-namespace X-JA4* spellings instead, and both sends each
fingerprint under both names. A backend written against X-JA4 therefore sees nothing
until you set legacy or both.
Sticky sessions
proxy:
sticky_sessions:
enabled: true
With this on, Synapse reads a backend_id cookie from the request and pins that
request to the backend it names. It is a cookie the response set, not a hash of the
client address — a client that sends no cookie is balanced normally.
A key: field under sticky_sessions parses but is never read; the cookie name is not
configurable.
Internal paths
internal: list does nothingA top-level internal: block parses, and is even checked for duplicate paths and for
collisions with your routes — and is then dropped. Nothing serves it.
Internal endpoints come from proxy.internal_services in config.yaml instead; the v1
internal_paths: key is the only file-level form that is actually applied.
Fields that parse but do nothing
Worth knowing, because the file loads without complaint:
| Field | Status |
|---|---|
internal (top level) | Parsed, validated, never applied |
tls.terminate.key | Parsed, dropped |
proxy.sticky_sessions.key | Parsed, never read |
lb.method on terminate hosts and routes | Validated, then dropped |
lb on a passthrough host that declares tls:/http: wires | Parsed, not validated, dropped — it belongs inside the wire block |
timeouts.read, .write, .idle on a passthrough host with no paths: | Parsed, unused — only connect applies. With paths: they are inherited by those routes |
What a bad file does
| Mistake | Outcome |
|---|---|
| Unknown or misspelled key, anywhere | Load fails |
connect: 30s (suffix on an integer timeout) | Load fails (type error) |
interval: 10 (no suffix on the one string duration) | Load fails |
Wildcard SNI other than one leading *. | Load fails |
A host with none of tls.terminate, tls.passthrough: true and passthrough:, or with more than one | Load fails |
cert and acme together | Load fails (neither is valid — it is terminate: {}) |
key under terminate without a cert | Load fails |
paths together with upstream, or neither, on a terminate host | Load fails |
Empty upstreams, or all weights 0 | Load fails |
Route path not starting with /, on a route without match_expr | Load fails |
default_host: with tls.terminate, tls.passthrough, a passthrough: block or listeners: | Load fails |
paths: on a wildcard passthrough host, on one that also passes plain HTTP, or an empty paths: map | Load fails |
path, interval or a consecutive_* key beside health_check: {type: none} | Load fails |
ssl_enabled beside type: none disagreeing with the backend's port | Load fails |
A health_check other than type: none on a match_expr route | Load fails |
A match_expr that does not compile, or that writes a header name with a capital in it | Load fails |
A match_expr route whose backends mix TLS and plaintext ports with no ssl_enabled | Load fails |
| Passthrough-only key on a terminate host, or the reverse | Load fails |
A transforms rule that does not compile | Configuration rejected — the file parsed, and a fixed edit recovers without a restart |
Bad headers: entry | Warning, entry skipped, file loads |
cert naming a certificate that was never scanned | Warning per handshake, default certificate served |
Backend address an IPv6 literal, or no :port | Silently skipped, no log; a route left with none falls through |
| Unknown listener name | Warning, entry dropped |
Remember the asymmetry above: at startup a failed load means no routing, while on reload the previous table keeps serving.
Migrating from v1
A file without version: 2 is parsed as v1, and v1 is still read — with a warning on
every load:
the upstreams file /etc/synapse/upstreams.yaml is in the v1 schema, which is
deprecated. It is still read. Move it to the v2 schema (`version: 2`, with `hosts:` in
place of `upstreams:`)
An empty document is exempt, being in no schema yet. The mapping:
| v1 | v2 |
|---|---|
upstreams: | hosts: |
upstreams.<host>.certificate | hosts.<host>.tls.terminate.cert |
upstreams.<host>.acme | hosts.<host>.tls.terminate.acme |
paths.<p>.servers: (string or {address, weight}) | upstream: for one, or upstreams: with addr/weight |
https_proxy_enabled (alias force_https) | force_https |
ssl_enabled, http2_enabled, disable_access_log, max_body_size, rewrite, redirect | unchanged, per route |
request_headers (alias headers), response_headers | headers.request / headers.response |
healthcheck, healthcheck_path, healthcheck_tcp | health_check: {type, path} |
healthcheck: false | health_check: {type: none} |
host key "*" | default_host: |
connection_timeout, read_timeout, write_timeout, idle_timeout | timeouts.connect / .read / .write / .idle |
allowed_methods | methods |
normalize_path, max_url_length, max_header_count | unchanged |
sticky_sessions | proxy.sticky_sessions.enabled |
config.forward_fingerprints, config.fingerprint_header_names | proxy.fingerprints.forward / .header_names |
provider | none — v2 is always a file, and the key is rejected outright |
internal_paths | none that works — see Internal paths |
Two more move rather than disappear: config.global_request_headers /
global_response_headers become the top-level headers.request / headers.response,
and config.healthcheck_interval / healthcheck_method leave this file altogether for
proxy.upstream.healthcheck.interval / .method in config.yaml.
provider other than file costs you the whole routing tablefile is the only value implemented. Anything else logs Unknown provider <value> and
loads nothing — at startup that leaves the proxy with no routing table and no file
watcher, so it cannot be fixed without a restart. v2 rejects the key instead, which is
the louder failure of the two.
These have no v2 form at all: config.https_proxy_enabled — there is no global
force-https, only the per-route force_https — along with globals and
authorization. match_expr routes are not among them: see
Routes chosen by an expression.
version falls back to v1 and loads almost nothingOnly the bare integer version: 2 works. A quoted version: "2" is recognised as v2
and then fails to load, because the field is an integer: invalid type: string "2", expected u32. version: 3, or a missing version, makes the file parse as v1 — where version: and hosts: are not keys at all, so
they land in an Unused upstreams config options (…) warning and the file loads as an
effectively empty v1 config. The proxy starts, routes nothing, and says so twice: that
warning, and the v1 deprecation notice, which any non-empty file that is not version: 2
earns. There is no "unsupported version" error for this.
There is no migration tool at 0.8.8; conversion is by hand.
Upstream DNS caching
When a backend is named by hostname, the resolved address is cached for
proxy.upstream.dns_cache_ttl_secs (default 60) rather than resolved per connection.
On Kubernetes that matters more than it sounds: without it every upstream connection
makes a DNS query, which puts CoreDNS in the request path of all your traffic.
Next steps
- Configuration Reference —
config.yaml, including theproxy.upstream.*keys that point at this file - Load Balancing — the balancing behaviour itself
- Kubernetes Ingress — generating this file from Ingress objects