Skip to main content

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.

A rejected file means no routing at all

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.

The sample shipped at config/upstreams.yaml is out of date

The 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
Unknown keys are a hard error, in all but three places

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​

KeyTypeRequiredDefault
versionintegeryes— (must be 2 for this schema)
hostsmap of SNI → hostnoempty (nothing is routed)
default_hostone host, without tls: or listeners:noabsent — a name no host matches gets 404
proxy.fingerprints.forwardboolnotrue
proxy.fingerprints.header_namesg0s | legacy | bothnog0s
proxy.sticky_sessions.enabledboolnofalse
timeoutsmapnosee Timeouts
headers.request, headers.responselist of "Name: value"noempty
transformsmapnoempty
methodslist of method namesnoall methods allowed
normalize_pathboolnounset (the client's spelling is kept)
max_url_lengthinteger (bytes)nounset (no limit)
max_header_countintegernounset (no limit)
internallistnoempty — 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.

  1. The name itself, compared the way DNS compares names: without the root dot and without regard to case. Example.COM., example.com. and example.com are one name, and a key written in any of those spellings serves all three.
  2. The wildcard with the longest suffix. *.example.com serves a.example.com and a.b.example.com alike — any depth — and never the bare example.com, which needs its own key.
  3. The default host, when the file declares one.

A name none of the three finds is answered 404.

A wildcard covers fewer names on the TLS side

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​

ModeHow you select itWhat it does
Terminatetls.terminate with cert, with acme, or empty (terminate: {})Synapse ends TLS, inspects HTTP, and proxies to the backend
Passthroughtls.passthrough: true, or a passthrough: blockThe 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:

KeyTerminatePassthrough
pathsyesyes, under three conditions — below
headers, transformsyesrejected
methods, normalize_path, max_url_length, max_header_countyesrejected
idle_timeout, max_connections, proxy_protocol_v2rejectedyes

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: trueLoad fails — passthrough picks its backend by name before any request is read
tls.terminate or tls.passthrough, in any formLoad 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.

In a v1 file the key "*" does this

A 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
A path here silently serves the wrong certificate

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.

A host can terminate without naming a certificate

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 discarded

A 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
email: [email protected] # optional; falls back to proxy.acme.email
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.

FieldTypeDefaultWhat it does
upstream"host:port"—One backend. Exclusive with upstreams
upstreamslist of "host:port" or {addr, weight}—Several backends; weight defaults to 1
methodslistinheritAnything else gets 405 with an Allow header — with two exemptions, below
normalize_pathboolinheritSend the backend the resolved path instead of the client's spelling
max_url_lengthbytesinheritOver the limit gets 414
max_header_countintegerinheritOver the limit gets 431
max_body_sizebytesunlimitedOver the limit gets 413
timeoutsmapinheritSee Timeouts
headers, transformsmapinherit + appendSee Headers
force_httpsboolfalseRedirect plaintext to HTTPS
ssl_enabledbooltrue for port 443 or 8443, else falseTLS to the backend — until the health probe observes otherwise
http2_enabledboolfalseOffer HTTP/2 to the backend. Decides anything only for a cleartext backend
disable_access_logboolfalseDrop 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?}probedSee Health checks
match_exprexpression 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_enabled is 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 explicit ssl_enabled: false to 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_enabled is 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_https only makes sense when a TLS listener exists. Configure it without one and Synapse warns once and keeps serving plaintext.
methods is not a complete gate

Two requests get past a methods: list:

  • HEAD rides along with GET. List GET and a HEAD is served too, and the Allow: header on a 405 advertises the implied HEAD alongside it. There is no way to list GET and refuse HEAD.
  • 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 narrow methods: 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:

FieldTypeNotes
http.request.pathstringalways present
http.request.methodstringalways present
http.request.hoststringthe name the client asked for, which for a wildcard or the default host is not the host key
http.request.querystringthe query string, without the ?; read on demand, see below
http.request.headersmap of name → list of valuesnames 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.

A capitalised header name is refused at load, not quietly unmatched

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.

An expression route is never health-probed

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 says type: none, rather than being accepted and ignored.
  • Its backends are reached by what ssl_enabled says, not by what a probe found — see What decides TLS to the backend. With ssl_enabled unset 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.

IPv6 literals do not work as terminate backends

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 hosts

On 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
TimeoutDefaultWhat it bounds
connect5Opening the connection to the backend
read60Reading the backend's response
write30Writing to the backend
idle60How long a pooled backend connection is kept for reuse
Plain integer seconds only

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 all

A 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 unit

Every 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 routeProbed?What decides TLS to the backend
probed — no health_check: block, or type: http / type: tcpyesthe 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 pathnothe 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_exprnossl_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 key

path, 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.

v2 refuses what v1 discards in silence

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
KeyDefaultNotes
idle_timeout3600Seconds. 0 means no bound
max_connectionsno capCounted per host across both wires. 0 is an error
proxy_protocol_v2falsePrepends 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.

An HTTP-only passthrough host still answers on 443

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:

ConditionWhy
The host key is an exact name, not a wildcardA 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: httpA plain-HTTP request for the name is copied to the backend before any path could match
paths: has at least one entryAn 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​

A v2 internal: list does nothing

A 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:

FieldStatus
internal (top level)Parsed, validated, never applied
tls.terminate.keyParsed, dropped
proxy.sticky_sessions.keyParsed, never read
lb.method on terminate hosts and routesValidated, then dropped
lb on a passthrough host that declares tls:/http: wiresParsed, 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​

MistakeOutcome
Unknown or misspelled key, anywhereLoad 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 oneLoad fails
cert and acme togetherLoad fails (neither is valid — it is terminate: {})
key under terminate without a certLoad fails
paths together with upstream, or neither, on a terminate hostLoad fails
Empty upstreams, or all weights 0Load fails
Route path not starting with /, on a route without match_exprLoad 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: mapLoad fails
path, interval or a consecutive_* key beside health_check: {type: none}Load fails
ssl_enabled beside type: none disagreeing with the backend's portLoad fails
A health_check other than type: none on a match_expr routeLoad fails
A match_expr that does not compile, or that writes a header name with a capital in itLoad fails
A match_expr route whose backends mix TLS and plaintext ports with no ssl_enabledLoad fails
Passthrough-only key on a terminate host, or the reverseLoad fails
A transforms rule that does not compileConfiguration rejected — the file parsed, and a fixed edit recovers without a restart
Bad headers: entryWarning, entry skipped, file loads
cert naming a certificate that was never scannedWarning per handshake, default certificate served
Backend address an IPv6 literal, or no :portSilently skipped, no log; a route left with none falls through
Unknown listener nameWarning, 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:

v1v2
upstreams:hosts:
upstreams.<host>.certificatehosts.<host>.tls.terminate.cert
upstreams.<host>.acmehosts.<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, redirectunchanged, per route
request_headers (alias headers), response_headersheaders.request / headers.response
healthcheck, healthcheck_path, healthcheck_tcphealth_check: {type, path}
healthcheck: falsehealth_check: {type: none}
host key "*"default_host:
connection_timeout, read_timeout, write_timeout, idle_timeouttimeouts.connect / .read / .write / .idle
allowed_methodsmethods
normalize_path, max_url_length, max_header_countunchanged
sticky_sessionsproxy.sticky_sessions.enabled
config.forward_fingerprints, config.fingerprint_header_namesproxy.fingerprints.forward / .header_names
providernone — v2 is always a file, and the key is rejected outright
internal_pathsnone 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.

A v1 provider other than file costs you the whole routing table

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

A typo in version falls back to v1 and loads almost nothing

Only 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​