Skip to content

Proxy configuration

The flat configuration hub remains the compatibility route. This page is the canonical source-checked field reference for its family, including nested tables and list rows.

Paths in the tables are relative to [proxy] and its nested headings. Each row gives the serde type and source default or unset meaning. Check parsing and listener/admission cross-field rules with gdsgate doctor --config <PATH> --json, then follow the network zoning guide.

[proxy]

Used by: Proxy.

Listener mode, admission control, and what the client-facing listener presents. Every admission limit defaults to 0, which means unlimited; set the ones you need. The join_*, public_join_*, public_renewal_max_connections and public_admin_max_connections keys are the exception: each carries a built-in figure, explained under the table.

Key Type Default Purpose
single_port string unset Multiplex the client gRPC API and the connector WebSocket fallback onto one listen address. Each connection is peeked (TLS ClientHello or HTTP upgrade) and routed accordingly; the mutual-TLS reverse tunnel stays on proxy_internal, and the join relay, when configured, keeps its own proxy_join address. Use it when egress only allows HTTPS on :443.
max_sessions usize 0 Cap on concurrent relayed client sessions. A session over the cap is refused cleanly at open. Bounds backend connections, relay tasks, and file descriptors.
max_streams_per_tunnel usize 0 Cap on concurrent backend streams a single connector tunnel may hold. Bounds per-connector fan-out and the blast radius of one noisy connector. The control-plane streams of a tunnelled connector (authority_transport = "tunneled") have a small ceiling of their own and do not spend this budget, so data-plane load can never leave a connector unable to reach Authority.
max_public_connections usize 0 Cap on concurrent connections to the public client-facing listener. A connection over the cap is shed at accept, bounding pre-auth pile-up. Does not affect connector-facing listeners.
internal_accept_rate_per_ip u32 0 Per-source-IP accept rate (tokens/second) on the connector-facing internal and WebSocket listeners. Over-budget source IPs are dropped at accept. Paired with internal_accept_burst.
internal_accept_burst u32 0 Token-bucket burst size for internal_accept_rate_per_ip. Either value being 0 disables the limiter.
public_anonymous_rate_per_ip u32 0 Per-source-IP call rate (calls/second) for the two RPCs on the public listener that answer before the caller has been authenticated: the settings bundle gdsgate connect fetches, and the liveness-and-version probe. Over-budget calls are refused (RESOURCE_EXHAUSTED); the authenticated RPCs beside them are never charged. Paired with public_anonymous_burst.
public_anonymous_burst u32 0 Token-bucket burst size for public_anonymous_rate_per_ip. Either value being 0 disables the limiter. One gdsgate connect spends two bundle calls, so a burst of 1 refuses first contact rather than pacing it.
max_bytes_per_second_per_session u64 0 Sustained bytes/second a single session may push, combined across both directions. Over-budget traffic is paced rather than dropped. Paired with session_rate_burst_bytes.
session_rate_burst_bytes u64 0 Token-bucket burst (bytes) for max_bytes_per_second_per_session. Set it to roughly one second of expected traffic so normal bursts are not throttled. Either value being 0 disables the throttle.
public_origin string unset The gateway's public browser origin (for example https://gate.example.com), the base of the WebAuthn step-up confirmation URL. Should match an entry in [mfa.webauthn].origins.
web_addr string unset Listen address for the browser-facing WebAuthn web-origin (HTTPS). Set with web_cert_file and web_key_file; unset, the web-origin is off.
web_cert_file string unset PEM certificate chain for the web-origin's TLS. Must be browser-trusted (a public or corporate CA), separate from the internal mTLS CA. gdsgate obtains nothing: whatever writes the pair replaces it on renewal, and the listener re-reads the files while it runs, so a renewal is not a restart.
web_key_file string unset PEM private key for web_cert_file. Naming one half of the pair and not the other is refused at start-up.
webapps_zone string unset Zone the clientless browser entry publishes applications under, as <application>.<webapps_zone>, with the label gdsgate reserved inside it for signing in. Unset, the entry is off, and the web lane is reached only the three ways that need gdsgate on the device. Naming some but not all four of this key and the three below is refused at start-up. See The clientless browser entry.
webapps_addr string unset Listen address for the clientless browser entry (HTTPS).
webapps_cert_file string unset PEM certificate chain the clientless browser entry presents: a browser-trusted wildcard for *.<webapps_zone>, separate from web_cert_file because it is a different name. That one is this gateway's own origin; this one is the zone the applications live in. gdsgate obtains nothing: whatever writes the pair replaces it on renewal, and the listener re-reads the files while it runs.
webapps_key_file string unset PEM private key for webapps_cert_file.
webapps_session_ttl_secs u64 28800 (8h) How long a browser session lasts, and with it how long the ticket minted for it is valid, and the longest a connection already relaying keeps relaying after the person signs out or the session expires on an instance that never saw it. Authority clamps it to a ceiling of its own (12h); a figure above that is silently the ceiling. It is not how long access survives a policy change: every request inside the session is decided again at the connector.
webapps_proxy_protocol_from array [] Addresses the clientless browser entry accepts a PROXY protocol header from, being the front the entry is published through, as single addresses or blocks (["10.4.0.0/24", "2001:db8::7"]). Empty, the header is never read and a browser's address is the address of the socket, which is the truth for a listener a browser reaches directly and a falsehood for one behind an SNI router, a load balancer or an nginx. Set, and the entry serves exactly one shape of connection: from a listed address, carrying a header. See Behind a front.
public_proxy_protocol_from array [] Addresses the public, client-facing listener accepts a PROXY protocol header from, being the front it is published through, as single addresses or blocks (["10.4.0.0/24", "2001:db8::7"]). The same setting as webapps_proxy_protocol_from for the other listener, and read apart from it, since one may be behind a front while the other is not. Empty, the header is never read and a caller's address is the address of the socket, which is the truth for a client that dials the gateway directly. Behind a front it is that front's address for every client at once, which decides context.src_ip, the src_ip of the audit row, and the pin a resource labelled pin_src_ip = "true" carries. Set, and the listener serves exactly one shape of connection: from a listed address, carrying a header. Covers single_port too. See Behind a front.
public_cert_file string unset PEM certificate chain the client-facing listener presents, for a gateway clients reach by its own domain name. With it set, a client needs no trust anchor of the cluster's, since its own trust store already holds the authority that signed this, and gdsgate connect writes a configuration without one. gdsgate obtains nothing: an ACME client or a corporate issuer writes the pair and replaces it on renewal, and the listener re-reads the files while it runs, so a renewal is not a restart. Unset, the listener presents the node's enrolled transport identity, which is what an on-premise cluster distributing its own anchor wants. Set with public_key_file; naming one half is refused at start-up.
public_key_file string unset PEM private key for public_cert_file.
public_join bool false Carry a node's join on the public, client-facing listener as well, so a node needs one address for its whole life. See the note below.
public_join_rate_per_ip u32 built-in 5 Per-source-IP call rate (calls/second) for the registration served under public_join. Its own bucket, so a burst of registrations cannot refuse the bundle or probe calls beside it. Paired with public_join_burst.
public_join_burst u32 built-in 10 Token-bucket burst for public_join_rate_per_ip. Either being 0 disables the limiter.
public_renewal_max_connections usize built-in 64 Ceiling on concurrent node renewals relayed off the public listener, the fleet's lane of the relayed branch. Raise it for a rollout that renews more nodes at once.
public_admin_max_connections usize built-in 8 Ceiling on concurrent administrative connections relayed off the same listener, told apart by the server name. Its own lane, so the two cannot spend each other's slots. See the two lanes.
join_max_connections usize built-in 64 Ceiling on concurrent relayed joins on the join relay. Raise it for a rollout that brings up more connectors at once than the built-in figure admits.
join_accept_rate_per_ip u32 built-in 5 Per-source-IP accept rate (tokens/second) on the join relay. Paired with join_accept_burst.
join_accept_burst u32 built-in 10 Token-bucket burst for join_accept_rate_per_ip. Either being 0 disables the limiter.
join_peek_timeout_secs u64 built-in 5 How long a connection to the join relay may stay silent before it is classified.
join_idle_timeout_secs u64 built-in 30 Idle ceiling on an established relay.

web_key_file, webapps_key_file, and public_key_file each accept the secret-reference notation in place of a bare path. A helper or variable named there is re-run on each certificate rotation, not only at start-up.

public_anonymous_rate_per_ip is counted per call

Everything else in this table counts sockets, sessions or bytes. Sockets are the wrong unit here: HTTP/2 multiplexes, so a single connection admitted by max_public_connections carries as many bundle calls as the peer cares to send, and a bundle is an amplifier, an empty request answered with a certificate.

It is charged on the two RPCs that answer before there is a caller to know, and on nothing else. The client's working path, requesting access and opening a session, is never charged, so an anonymous flood cannot throttle real work. The two hold separate buckets under the one pair of keys, because a flood of the bundle must not spend what the next gdsgate ssh needs from the probe.

Size it for your client population, not for a flood. The probe opens every gdsgate login, gdsgate ssh and gdsgate db login, so the figure has to clear what one source address runs, and an office behind one NAT address, or this listener behind an L4 front that does not preserve the client address, is one source address for the whole population. Anything outside gdsgate that polls the bundle in a loop spends from its own address's budget, so count it in too. Refusals are counted in gdsgate_public_anonymous_refused_total and not logged per call: a log line per refused call would be an amplifier of its own.

Defaults for the join_* and public_join_* keys

Everywhere else in [proxy], a key left out means unlimited. In these it means the built-in figure, because what they guard answers before any authentication. A stated 0 is unlimited, exactly as it is above, so a site that means "no ceiling" has to write it.

public_join: the join on the client-facing port

A node's one address can be the gateway's own. With public_join = true the client-facing listener carries both halves of a join as well, and a node that was given https://proxy.<domain> and a token needs nothing else for the rest of its life: no second port, and no address derived from a convention only an installer knows.

The two halves are served differently:

Half On the public port Destination
registration terminated here and forwarded as a call [endpoints].authority_enroll
token-less renewal not terminated; relayed byte for byte [endpoints].authority

Renewal presents the node's own certificate and Authority authenticates that certificate, resolving the node's role and registry row from the verified leaf, so a gateway that terminated it would hand Authority its own identity and the node would be refused. Registration presents no certificate at all: the one-time token in the request is the whole proof, so terminating takes nothing from it.

They are told apart by the TLS server name. A renewal asks for the internal transport name (gdsgate.internal), which only this cluster's own certificates carry; a client, and a registering node, which dials the same URL a client does, asks for the gateway's public name. A connection that names nothing is a client dialling by address and is served as one. Nothing a caller sends chooses a destination: both addresses come from the Proxy's own configuration.

This does not move the join relay's per-source-IP accept rate. On a port that multiplexes, an accept rate bounds nothing, since one connection carries any number of calls, and on a port a whole office reaches through one NAT address it would be a limit on the office. It becomes the call budget public_join_rate_per_ip instead, in a bucket of its own; the connection ceiling (max_public_connections) keeps covering a relayed renewal for as long as it is open.

Relayed lanes and their ceilings

The relayed half carries more than renewal. The names it takes off the port are the whole internal family, and one of them, auth.gdsgate.internal, is what a caller holding a control-plane certificate asks for when it means to reach Authority's administrative surface instead of renewing. Both are the same unterminated branch and neither may be terminated, so they are one listener; they are two different populations, so they are two lanes with a ceiling each:

Lane Server name Ceiling
a node renewing gdsgate.internal (and the rest of the family) public_renewal_max_connections
a control-plane certificate administering auth.gdsgate.internal public_admin_max_connections

A fleet rolling out cannot refuse an operator, and an operator's tooling cannot refuse a node whose identity is expiring. Both sit under max_public_connections, which counts every connection this listener accepts, so size that above the two. The figures are for two different populations: renewal is one call a day per node plus retries, while administration is serial per caller, one connection per command.

The lane grants nothing. Authority completes the handshake itself, so a certificate it did not issue is refused there, and one it did issue is gated on the role in its subject: a connector's certificate reaches the administrative surface and is refused by it. What the lane decides is which ceiling a connection is counted against.

Reaching Authority this way is what [admin].transport = "fronted" is for, and it has one cost: the address in an administrative audit row is the gateway's rather than the operator's. The gateway relays opaque bytes and never sees the request, so there is nothing for it to annotate, which means a policy rule that admits administration only from a corporate network does not work on this path. Administration on a direct dial to Authority is unaffected.

Off by default. It is a surface that answers before the caller has been authenticated, and an upgrade must not hand one to a gateway whose operator did not ask for it; proxy_join remains the way to put the same two destinations on an address of their own. The two can run side by side.

On the tunnelled route this is also what Authority advertises as the renewal address, so it belongs in Authority's configuration too; see what Authority tells a joining node.

[proxy]
# Share :443 with the connector WebSocket fallback (corporate egress).
single_port = "0.0.0.0:443"

# Browser WebAuthn web-origin: the passkey ceremony runs against the gateway
# domain over a browser-trusted cert (not the internal mTLS CA).
public_origin  = "https://gate.example.com"
web_addr       = "0.0.0.0:8443"
web_cert_file  = "/etc/gdsgate/web-cert.pem"
web_key_file   = "/etc/gdsgate/web-key.pem"

# Admission control (all default to 0 = unlimited).
max_sessions                 = 2000
max_streams_per_tunnel       = 256
max_public_connections       = 5000
internal_accept_rate_per_ip  = 20
internal_accept_burst        = 40

# What one source address may ask of the two unauthenticated RPCs. Ten calls a
# second is far above what a client population runs and far below what a sweep
# wants; the burst of 30 absorbs a room starting up at once.
public_anonymous_rate_per_ip = 10
public_anonymous_burst       = 30

# A publicly trusted certificate for the client-facing listener, renewed by
# something outside gdsgate. Clients then need no anchor of the cluster's.
public_cert_file = "/etc/gdsgate/public-fullchain.pem"
public_key_file  = "/etc/gdsgate/public-key.pem"

# Join relay: a rollout larger than the built-in 64 concurrent joins.
join_max_connections    = 512
join_accept_rate_per_ip = 50
join_accept_burst       = 100

# Carry a node's join on the client-facing port as well, so a node needs one
# address for its whole life. Off unless stated; the figures are the built-in
# ones, shown here for visibility.
public_join             = true
public_join_rate_per_ip = 5
public_join_burst       = 10

# The two lanes of the relayed half, sized for two different populations.
public_renewal_max_connections = 64
public_admin_max_connections   = 8

Three certificates the Proxy presents outward, public_cert_file, web_cert_file and webapps_cert_file, are issued and renewed by something other than gdsgate, and every one of their listeners re-reads its files while the process runs. A renewal is a file replacement; the next connection is handed the new certificate and the ones already established are not disturbed. That matters most here, because these listeners share their process with the data plane, and restarting the Proxy to pick up a certificate would end every session it carries. See Renewing a listener certificate for what happens to a pair caught half-written, and for the SIGHUP that makes a renewal visible immediately instead of within the second.

web_addr also serves the key set that verifies the identity assertions the web lane hands applications: GET /.well-known/gdsgate/jwks.json on this gateway's own origin, not the customary bare /.well-known/jwks.json, since other things live under .well-known on this origin too. It renders the same trust bundle Authority keeps for CA rotation, filtered to the JwtSigning kind, with Cache-Control: public, max-age=300, one figure that is both how long the Proxy holds its own copy and how long an application is told it may hold its. See Telling the application who came.

The clientless browser entry

A person on a device with no gdsgate on it opens https://<application>.<webapps_zone>, signs in at the identity provider, and reaches the application. It is the one way into the web lane that needs nothing installed; the other three (a local listener, an address inside a delegated program's cage, a name the resident client publishes) all need it. webapps_zone, webapps_addr, webapps_cert_file and webapps_key_file have to be set together or not at all: a zone with no listener answers nothing, and a listener with no zone does not know which application a name means.

One certificate, one listener, one reserved name. Every application is published on its own subdomain of the zone (grafana.<webapps_zone>). The certificate is an ordinary public wildcard, and a wildcard covers one label, so grafana.team.<webapps_zone> is not served. The label gdsgate inside the same zone is reserved for signing in: the identity provider is registered with one exact redirect URI, which a wildcard cannot be, and putting sign-in on this gateway's own public origin instead would give the sign-in cookie a Domain covering every application behind it instead of this one zone. An application catalogued under the reserved name is unreachable through this entry; the surface says so once, in the log; it does not serve a page that resolves, answers, and is the wrong one.

A browser is admitted over a certificate, as every other client is. There is no key on a borrowed device to bind one to, so Authority signs a ticket instead of the usual access certificate: the same connect decision, over the same WebApp, with the certificate's own fields put to a browser's use. The public-key field carries a SHA-256 digest of the session cookie rather than a key; pinned_ip is always the observed address, since a cookie is portable and the address is the other half of what proves this browser is the one it was issued to; and its window is the browser session's own, webapps_session_ttl_secs clamped to Authority's ceiling of 12 hours. The connector checks a ticket exactly as it checks every other certificate, with the same trust root, scope and pin, and knows nothing about browsers to do so. A ticket copied off this gateway is useless anywhere else: redeeming one means signing a nonce with the key named in it, and no key was ever named. Authority accepts a ticket request only over the Proxy's own mTLS identity.

Behind a front, the address has to be handed over. The address the entry observes on a connection is used three times: it binds the browser session, it is what the ticket above is pinned to, and it is context.src_ip in the decision. Published through an SNI router, a load balancer or an nginx, that address is the front's, which makes the pin bind every ticket to one address, so a session cookie lifted off one machine works from another, and makes context.src_ip.isInRange(...) a statement about the front. webapps_proxy_protocol_from names the addresses a PROXY protocol header (versions 1 and 2) is accepted from; the front has to send one (proxy_protocol on in an nginx stream block). It is a list rather than a switch, because the header carries no signature: it is true because of who sent it. With it set, this listener admits one shape of connection and closes the rest without answering. A header from an address not on the list is somebody choosing their own source address, and a connection with no header is a front that was half configured, which would otherwise be indistinguishable from a working deployment until somebody read a pinned address. Empty is the default and means the header is never read.

The identity token is spent once and kept nowhere. The id_token a sign-in exchanges at the provider is used to ask for one ticket and then dropped, so this listener never holds a reusable identity for anybody signed in, only a set of narrow, pinned, expiring tickets. The visible cost is that a second application, inside the same browser session, is a second trip through the identity provider: a pair of redirects where the provider's own session is still live, an ordinary sign-in where it is not.

The session lives in this process's memory and nowhere else. The cookie (Secure, HttpOnly, SameSite=Lax, scoped to webapps_zone) is an opaque handle; who is signed in, since when, which applications, and until when are this process's own state, never the cookie's, so a session this process does not hold is a session that does not exist. Sign-out, posted from the surface's own page with the surface's own token, removes the entry and cuts every relay it opened; expiry ends a session at the ticket's not_after, the same certificate boundary every other lane enforces, and a relay already open is cut there too. Neither is a promise about the next instant policy makes on its own: every request inside a session is decided again at the connector.

Limitations of this surface:

  • A browser session is not in ListSessions / KillSession. The kill registry is built inside the gRPC service this listener sits in front of, and does not see it.
  • The session table is in-memory and per-instance. A Proxy restart signs every browser out, and behind more than one Proxy instance a session answers only on the instance that opened it, since the ticket is pinned to the browser's own address rather than to an instance.
  • single_port is refused at start-up together with this entry. The shared port is multiplexed by peeking the TLS ClientHello; this listener's handshake ends in a certificate for the application zone's own wildcard, and there is nothing left to peek at that would tell the two apart.