Behind a reverse proxy¶
Caravel serves plain HTTP and does not terminate TLS. For anything reachable beyond your own network, put a reverse proxy in front of it — Caddy, nginx, Traefik — and let that handle certificates.
Everything on this page is what the code actually does, including where that is not quite what the convention would suggest.
Set X-Forwarded-Proto¶
This is the one header Caravel reads, and it matters. The session cookie's
Secure attribute is set when the request arrived over TLS or when
X-Forwarded-Proto: https is present. Without it, a proxy terminating TLS
leaves Caravel believing the connection is plain HTTP, and the cookie goes out
without Secure.
Trusting that header is safe here because it can only ever add Secure, never
remove it — r.TLS is checked as well. The worst a spoofed header can do is make
a browser refuse to store a cookie set over plain HTTP.
Caddy sets X-Forwarded-Proto itself. Nothing to configure.
Traefik sets the forwarded headers by default. The only thing to check is the
request body limit if you have a buffering middleware in the chain.
Set X-Forwarded-For¶
Caravel keys its rate limiters, and the address recorded against a session, on
who the request came from. Behind a proxy that means reading
X-Forwarded-For — without it every request looks like the proxy, and these
per-address limits become instance-wide:
| Endpoint | Limit |
|---|---|
| Login and register | 10/minute per address |
Address search (/api/geocode) |
20/minute per address |
| Image search | 10/minute per address |
| The assistant | 6/minute per address (CARAVEL_ASSIST_RATE_LIMIT) |
The header is read only when the machine Caravel is talking to is a trusted proxy, which by default means the private address space:
So the ordinary arrangements — a proxy on the same host, or elsewhere on your LAN or a container network — work with no configuration beyond setting the header in the proxy. An instance exposed directly to the internet is unaffected too: the peer address is public, so it is not trusted and no header is read.
Set CARAVEL_TRUSTED_PROXIES when neither of those describes you:
| You want | Set it to |
|---|---|
| A proxy at a public address | that address or range, e.g. 203.0.113.7 |
| Several, or a mix | a comma-separated list: 10.9.0.0/16, 203.0.113.7 |
| To trust nothing at all | none |
Whatever you set replaces the defaults rather than adding to them, so naming your own proxy also stops loopback and the private ranges being trusted.
Who can forge this
Trusting a network means trusting everyone on it. Someone who can already
reach Caravel from a private address can send an X-Forwarded-For of their
choosing and pick which bucket the rate limiters count them in. On a
household instance that is nobody who is not already inside. If it is not
your situation — a shared container host, a large office LAN — narrow
CARAVEL_TRUSTED_PROXIES to your proxy alone.
100.64.0.0/10 is not in the defaults, although some frameworks include it.
That is Tailscale's range, and on a tailnet those addresses are usually the
people using the app rather than a proxy in front of it.
X-Real-IP is not read at all. It carries a single address and no chain, so
there is no way to tell how many hops it crossed or who last wrote it.
Allow large enough request bodies¶
Uploads are capped by Caravel at 50 MB, for both documents and images. A proxy with a smaller body limit will reject the upload first, and the error the user sees will be the proxy's rather than a useful one. nginx defaults to 1 MB, which is the common surprise.
Headers Caravel sets, and two it does not¶
Every response carries:
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-origin
There is no Strict-Transport-Security and no Content-Security-Policy.
HSTS belongs at the proxy, which is the thing that knows whether TLS is set up
and permanent; adding it is a good idea once your certificate renewal is
reliable. A CSP is simply not implemented.
Leave the caching headers alone¶
Caravel decides how long a browser may keep each file, and the scheme only works if the proxy passes those decisions through:
- The app's code lives under
/assets/, and the files it loads by path (fonts, icons, locales, the map library and styles) under/v/<hash>/. A new version is always a new URL, so these are sent withCache-Control: public, max-age=31536000, immutableand a browser never asks about them again. - The page itself and
/sw.jsare sent withCache-Control: no-cacheand anETag. That is what lets a browser find out about a new version on the next load, without a forced reload.
So do not override Cache-Control, and do not let a caching proxy serve
/, a page route, or /sw.js from its own cache without revalidating. That
would hold every visitor on the previous version after an upgrade. Compression
at the proxy is fine; Caravel gzips its responses itself as well.
Sessions and CSRF, so you know what you are proxying¶
Sessions are opaque random tokens in an HttpOnly, SameSite=Lax cookie, and
only a SHA-256 hash of the token is stored server-side — a database leak does not
hand over usable sessions.
There is no separate CSRF token, deliberately. SameSite=Lax cookies are not
sent on cross-site POST/PUT/PATCH/DELETE requests, only on cross-site
top-level navigation, which is always a GET here — so a malicious site cannot
ride a logged-in session to change data. The request arrives with no session
cookie and gets a 401.
The consequence for a proxy is that Caravel must be served from one origin.
Serving the API and the frontend from different hostnames breaks the assumption
that makes SameSite=Lax sufficient.
A sub-path is not supported¶
Caravel expects to own the root of its hostname. There is no configurable base
path, and the frontend's routes and asset URLs are absolute, so
https://example.org/caravel/ does not work. Give it a hostname or a subdomain
of its own.