Docs Self-hosting Behind a reverse proxy
Behind a reverse proxy
A reverse proxy gives the worker and the console their HTTPS names, two names of one domain (see
Before you start). Set FRUITBACK_PUBLIC_URL and FRUITBACK_CONSOLE_URL in
.env to those two addresses: the console calls the first, and a sign-in link opens the second. When
a proxy is in front:
- Stop publishing the two ports to the internet. A port of the worker that stays public lets a
caller skip the proxy and write its own
X-Forwarded-For. Which way depends on where the proxy runs:- The proxy runs on the host (nginx or Caddy installed with the system): set
FRUITBACK_PORT=127.0.0.1:8080andFRUITBACK_CONSOLE_PORT=127.0.0.1:8081in.env. The port of the worker then answers only on the host’s loopback (measured), and the compose file stays as downloaded. - The proxy runs in a container (Traefik, Dokploy): the host’s loopback is not the proxy’s.
Remove the
portsmappings and put the worker and the console on the proxy’s network. The compose file’s commented Traefik block has these steps for the worker; the console takes the same labels with its own name and port8080. Keep your edit when you download a newer file. Not measured here with the console.
- The proxy runs on the host (nginx or Caddy installed with the system): set
- Set
TRUSTED_PROXY_HOPSto the number of proxies between the internet and the container. - Run the check below from a machine outside your network.
docker-compose.yml carries a commented Traefik block with these three steps. For a proxy on the
host, the minimal configurations are:
# nginx: TRUSTED_PROXY_HOPS=1
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}
$remote_addr replaces the header with the address nginx saw, so a forged value never reaches the
worker. Many nginx examples write $proxy_add_x_forwarded_for instead, which keeps what the caller
sent: with that, a count one too high lets a caller escape the rate limit. Use it only when nginx
sits behind another proxy that it trusts.
# Caddy: TRUSTED_PROXY_HOPS=1
api.feedback.example.com {
reverse_proxy 127.0.0.1:8080
}
What TRUSTED_PROXY_HOPS does
The rate limit keeps one bucket per client address. The worker reads that address from
X-Forwarded-For: the entry TRUSTED_PROXY_HOPS places from the right. When the header has fewer
entries than that, the worker uses the address of the connection, which is the nearest proxy, and
every caller then shares that proxy’s bucket.
Proxies do not all write the header the same way, and that changes what a wrong count costs.
Measured: the worker behind each proxy, a caller sending X-Forwarded-For: 1.2.3.4, then
24 reads that each forged a different address, against the default limit of 20.
| In front of the worker, default settings | The worker receives | 1 |
2 |
3 |
|---|---|---|---|---|
| Traefik v3.5 | the client’s address only | right | shared bucket | shared bucket |
| Caddy 2.10 | the client’s address only | right | shared bucket | shared bucket |
nginx 1.29, $proxy_add_x_forwarded_for |
1.2.3.4, <client> |
right | bypassed: 24 × 200 |
shared bucket |
nginx 1.29, $remote_addr |
the client’s address only | right | shared bucket | shared bucket |
| nginx, then Traefik | nginx’s address only | shared bucket | shared bucket | shared bucket |
| nginx, then Traefik trusting nginx | 1.2.3.4, <client>, <nginx> |
shared bucket | right | bypassed: 24 × 200 |
Right and shared bucket both answered 20 × 200 then 4 × 429: from one caller they look the
same. Which one it is comes from the rule above, and the check below tells them apart.
What to take from it:
- Traefik and Caddy replace the header with the address they saw, so a forged value never reaches the worker. A count that is too high there does not open the limit; it makes everyone share one.
- nginx does what its configuration says. With
$proxy_add_x_forwarded_forit keeps what the caller sent and appends, and a count that is too high hands the rate limit to the caller. With$remote_addrit replaces, like Traefik. - A proxy behind another proxy must trust it, or the client’s address is lost at every count. For
Traefik that is
--entrypoints.<name>.forwardedHeaders.trustedIPs=<address of the proxy in front>. The first proxy, the one facing the internet, must trust nobody.
Check it
From a machine outside your network, send 24 reads with forged addresses, on a page of a site that
everyone reads, with the id of that site. A read that names no site answers 400, and counts all the
same.
URL='https://api.feedback.example.com/feedback?url=https%3A%2F%2Fstaging.example.com%2F&client=site_…'
for i in $(seq 1 24); do
curl -s -o /dev/null -w '%{http_code}\n' -H "X-Forwarded-For: 198.51.100.$i" "$URL"
done | sort | uniq -c
- 24 ×
200: the count is too high, and anyone can escape the limit. Lower it by one and check again. -
20 ×
200and 4 ×429: the limit holds. Now, within the same minute, read once from a second network (a phone on mobile data):curl -s -o /dev/null -w '%{http_code}\n' "$URL"200means each caller has its own bucket, and the count is right.429means every caller shares one: the count is too low, or a proxy lost the client’s address.
With another RATE_LIMIT_PER_MINUTE, expect that many 200 answers before the 429s, and send a
few more reads than the limit.
Deploying with Dokploy
Dokploy is one way among others: it runs the containers behind its own Traefik, which gives the worker
and the console their HTTPS names. Fruitback Cloud runs this way (apps/worker/cloud/README.md). This
section was not replayed for this page. Create a PostgreSQL service in the project, then an
« Application » for the worker with either source:
| Setting | From the image | From the repository |
|---|---|---|
| Provider | Docker, ghcr.io/sakuga-software/fruitback-worker:edge or a digest |
GitHub, this repository |
| Build type | n/a | Dockerfile, path apps/worker/Dockerfile, build context . |
| Port | 8080 |
8080 |
Then:
- Set the environment in Dokploy. It does not read
docker-compose.ymlor.env. The names are the ones in Every environment variable.LINEAR_API_KEYbelongs in Dokploy’s environment, never in the image or the repository. TRUSTED_PROXY_HOPS=1for Dokploy’s Traefik alone, then check it. For a proxy in front of Dokploy, such as a CDN, first make Dokploy’s Traefik trust it (forwardedHeaders.trustedIPs), then add one. Without the trust, Traefik replaces the header and the client’s address is lost at every count (measured with nginx in front of Traefik).FRUITBACK_DATABASE_URLis the internal address Dokploy gives the PostgreSQL service. Back it up as Back it up says, withpg_dumpin that service’s container.- The console is a second Application, from
ghcr.io/sakuga-software/fruitback-console:edge, port80, withFRUITBACK_APIset to the HTTPS address of the worker. See The console. /healthis the readiness probe. It answers503while the configuration is refused, so a bad deploy gets no traffic. The process drains its requests onSIGTERM.