Skip to main content
Version: Next

Reverse proxy

Fadebox's control plane serves plain HTTP and expects a TLS-terminating proxy in front of it. The Compose file in Installation publishes 8080 and leaves the proxy to you.

This is the control plane — the URL you sign in to. TLS for deployed instance URLs is a separate layer with its own edge proxy, already handled by the ingress bundle.

What the proxy has to send​

X-Forwarded-Proto is the header that matters. Everything Fadebox says about where it lives is built from the request as it arrives, so without it the server believes it is plain HTTP:

  • the session cookie is marked Secure only when the request looks like https, and a cookie without that flag is one the browser will also send over plain HTTP;
  • sign-in redirects are absolute, so a wrong scheme bounces a user back out of https;
  • the audit log's client address comes from X-Forwarded-For — without it every event records the proxy's own address, the same value for every user;
  • the throttle on failed sign-ins and keys counts per client address, and it believes a forwarded one only once QUARKUS_HTTP_PROXY_TRUSTED_PROXIES names the proxy — until then every client behind the proxy shares one allowance;
  • the OAuth document for MCP must advertise the exact URL the client dialled, and a client that connected over https rejects an endpoint that calls itself http.

Send all three: X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-For.

X-Forwarded-Host is the one you may not actually need. Most proxies pass the browser's Host through untouched, and that is what Fadebox uses when the header is absent — the two agree, so sending it changes nothing. It is honoured because some setups replace Host with the backend's name (a few proxy_pass configurations, some path-based routing, some cloud load balancers), and Fadebox would otherwise advertise its own container address back to the browser.

:::warning Strip what the client sent

Fadebox reads the leftmost value of X-Forwarded-For. A proxy that appends to the header rather than replacing it therefore lets any caller choose the address written to the audit log, just by sending an X-Forwarded-For of their own. Delete the inbound forwarding headers at the edge before adding your own — the blueprints below do.

:::

Traefik​

Traefik sets all three headers itself; there is nothing to configure for them. The usual case is that Traefik is already running on the host — it is what the ingress bundle installs — so the control plane joins the same edge network and asks for a router:

services:
app:
image: fadebox/fadebox:1.5
# No `ports:` — with Traefik in front, publishing 8080 is exactly what makes the
# forwarding headers forgeable by anything else on the host.
environment:
# …the variables from Installation…
# The address the router below serves. A gated instance's sign-in returns here, and with
# 8080 unpublished a managed ingress stack reaches Fadebox here too.
FADEBOX_PUBLIC_URL: https://fadebox.example.com
QUARKUS_HTTP_PROXY_TRUSTED_PROXIES: 172.16.0.0/12
networks: [default, fadebox-edge]
labels:
- traefik.enable=true
- traefik.docker.network=fadebox-edge
- traefik.http.routers.fadebox.rule=Host(`fadebox.example.com`)
- traefik.http.routers.fadebox.entrypoints=websecure
- traefik.http.services.fadebox.loadbalancer.server.port=8080

networks:
fadebox-edge:
external: true

Three things worth knowing:

  • traefik.enable=true is required, because the bundle runs Traefik with providers.docker.exposedbydefault=false — only containers that ask for it become routable.
  • The certificate has to cover this host. The bundle's websecure entrypoint carries one wildcard for the runtime domain, so fadebox.example.com is covered only if it sits under that domain. Otherwise give the router its own resolver (traefik.http.routers.fadebox.tls.certresolver=le).
  • Leave forwardedHeaders.trustedIPs unset on the public entrypoint. Unset, Traefik overwrites the forwarding headers on every request, which is what you want; listing an address there tells Traefik to pass that peer's own headers through untouched.

Set QUARKUS_HTTP_PROXY_TRUSTED_PROXIES to the network Traefik reaches Fadebox on — docker network inspect fadebox-edge prints the subnet.

HAProxy​

HAProxy needs saying outright: option forwardfor sets X-Forwarded-For and nothing else, and it appends.

global
log stdout format raw local0

defaults
mode http
log global
option httplog
timeout connect 5s
timeout client 5m
timeout server 5m

frontend fadebox
bind :80
bind :443 ssl crt /etc/haproxy/certs/fadebox.example.com.pem alpn h2,http/1.1
http-request redirect scheme https unless { ssl_fc }

# Drop whatever the caller sent before adding our own. Fadebox reads the leftmost
# X-Forwarded-For, so an appended value would let a caller pick its own audit-log address.
http-request del-header X-Forwarded-For
http-request del-header X-Forwarded-Proto
http-request del-header X-Forwarded-Host

option forwardfor
# Everything past the redirect above arrived over TLS, so this needs no condition.
http-request set-header X-Forwarded-Proto https
http-request set-header X-Forwarded-Host %[req.hdr(host)]

default_backend fadebox_app

backend fadebox_app
server app 10.0.0.5:8080 check

set-header rather than add-header throughout, so a value a client supplied is replaced instead of appended to.

The five-minute timeout server is deliberate: the MCP endpoint at /mcp can hold a long-lived response stream, and the default one minute cuts it. If you front only the UI and the REST API, the default is fine.

Traefik and Caddy send all three from their usual proxy directives, and replace what the client sent. Nginx sends none of them: a bare proxy_pass forwards the request with no X-Forwarded-* at all, so they have to be spelled out — the distro snippets that appear to do it for you (proxy_params and friends) are just these lines in a file:

location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}

$remote_addr rather than $proxy_add_x_forwarded_for, so the value is the peer nginx actually sees instead of whatever the client sent with the peer appended — the same reason the HAProxy config deletes the inbound headers first.

WebSocket upgrades​

The instance agent and fadebox connect hold WebSockets open through the proxy: on /api/agent/control, /api/agent/streams/ and each instance's …/connect/ path under /api/projects/. The proxy has to pass the upgrade and keep an idle connection open for at least a minute. Fadebox pings every one of these WebSockets every 30 seconds — the agent's control connection and both sides of each open fadebox connect connection — so an idle database session stays open through a proxy that closes idle connections after 60 seconds or more. Traefik and Caddy pass upgrades with no configuration.

HAProxy passes the upgrade by itself in mode http; there are no headers to add. Once a connection is upgraded, HAProxy holds it to timeout tunnel instead of timeout client and timeout server — and where no timeout tunnel is set, the five-minute timeouts above apply, which is enough. A timeout tunnel you set has to stay above 30 seconds. To give only these paths a long one, set it per request in the backend (HAProxy 2.4 or later):

backend fadebox_app
# The agent's control and stream WebSockets, and each instance's connect path.
acl fb_ws path_beg /api/agent/
acl fb_ws path_reg ^/api/projects/[^/]+/environments/[^/]+/instances/[^/]+/connect/[^/]+/[0-9]+$
http-request set-timeout tunnel 1h if fb_ws

server app 10.0.0.5:8080 check

set-timeout tunnel is only allowed where a backend is, so the rule sits in backend, not in frontend. A timeout tunnel 1h under defaults does the same for every upgraded connection.

Nginx needs the upgrade headers forwarded:

location / {
proxy_pass http://127.0.0.1:8080;
# … the X-Forwarded-* lines above …
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}

$connection_upgrade comes from a map in the http block, so an ordinary request still sends Connection: close:

map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}

Nginx's default proxy_read_timeout of 60 seconds is enough for the 30-second pings. If you set it yourself, keep it above 30 seconds.

The agent is on by default. If you switch it off, none of this is needed.

Lock the trust down​

The same trust that makes the headers work makes them forgeable. QUARKUS_HTTP_PROXY_TRUSTED_PROXIES names the proxy's own address (IP or CIDR, comma-separated); while it is unset, every peer is trusted and anything that can reach the HTTP port by another route can choose the scheme and host Fadebox advertises and the client address it records. Fadebox logs a warning at startup until you set it.

Not publishing port 8080 at all is the other half of the same job, and the stronger one: a port nothing can reach directly cannot be addressed directly.

See Configuration reference for both proxy variables.

Checking it worked​

The sign-in challenge is an absolute URL built from the request, so it reports what the server believes about itself — no credentials needed:

curl -sI https://fadebox.example.com/api/me | grep -i '^location:'
location: https://fadebox.example.com/sign-in

An http:// scheme or the wrong host there means the headers are not arriving. Once that line is right, sign in and check the session cookie in your browser's devtools: quarkus-credential should be flagged Secure, HttpOnly and SameSite=Strict.